Repository navigation
548 lines (520 loc) · 31.5 KB
/
Copy pathadvance-deploy-env.yml
File metadata and controls
548 lines (520 loc) · 31.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
# GENERATED FILE -- DO NOT EDIT BY HAND.
#
# Self-contained copy of the reusable workflow `.github/workflows/advance-deploy-env.yml`
# (blob 96dd83eb8f536d153241cc664a940c727d9cbb07) folded with the private source repo's own caller of it,
# `.github/workflows/advance-deploy-env-caller.yml` (blob 5de24d409e5bbf2bccbb720912cd551cbe33b040), for a PUBLIC repo: GitHub lets a
# public repo `uses:` a reusable workflow only from a public repo, and the
# reusables live in the private source repo. So this file carries the
# reusable's jobs inline -- the triggers are the caller's; every `inputs.*` is
# resolved to a literal (repo-inventory.yml's `caller_inputs` floor, else the
# reusable's default -- the caller's own toolchain choices are NOT copied); the
# App-token secrets are named directly; a job whose condition resolves to a
# constant false is omitted; and each job's display name keeps the
# `advance / ` prefix so the check contexts branch protection requires do
# not change. Scripts are still fetched from the private source repo at run time with a
# read-only App installation token; on a fork PR that token cannot be minted
# (no secrets), so the checkout fails closed rather than running untrusted input.
# Where the copy checks the private source repo out, its `tools-ref` is the literal `main` --
# the ref every caller of the reusable pins -- because a public copy has no
# reusable to take the commit from (the private original defaults to its own).
#
# PUBLIC SURFACE. This file is delivered to a public repo. Exactly what was
# scrubbed, and what was not:
#
# * COMMENT TEXT -- a whole-line YAML `#` comment, a `#`-first line of a `run:`
# script that is not inside a heredoc, and this header -- was scrubbed: every
# private-repo issue reference, private repo name (org-qualified OR bare),
# RFC id and tenant name became `(internal ref)`, and the private repo this
# file was generated from became `the private source repo`.
# A bare name is left alone where the next character is `/` or `#`: that is a
# path or branch prefix, or the issue form, which is matched on its own.
# A name that is also an ordinary English word, declared as such on the
# inventory row, is left alone in prose as well: it is scrubbed only inside a
# code span, as `<org>/<repo>` (URL or not), or as `<repo>#N`.
# * CODE -- everything else: commands, heredoc bodies, strings a workflow posts,
# `repository:` values, URLs, step names -- was NOT edited. An internal
# reference there refuses the render (it is a defect in the source). A BARE
# private repo name is never judged in code at all: several are also public
# product identifiers -- an image a public chart installs, a package name, a
# branch prefix -- so only the org-qualified spelling refuses there, with one
# exception: the name of the private repo this file was generated from, which
# the code states verbatim because the workflow checks that repo out and
# fetches its scripts from it at run time.
#
# Regenerate: python3 scripts/render-self-contained.py (in the private source repo)
# Verify: python3 scripts/render-self-contained.py --check (make selftest-self-contained)
#
# The comments below are the reusable's own and describe the reusable; where
# they mention `inputs.*` or callers, read them as history.
name: Advance deploy environment
# Reusable workflow. Called from each active repo on push to develop/staging/master/main.
# For each PR contained in the push, updates the Deploy environment project field
# and advances Status through the multi-stage validation flow:
# develop -> Status = "On dev" (automatic; no dev-side review — D6)
# staging -> Status = "FR on staging" (functional review on staging environment),
# or "Ready for prod" where the repo's repo-inventory.yml row declares
# `staging_surface: none` -- nothing of it is on staging to review
# ((internal ref); scripts/branch_status_map.py reads the row)
# master/main -> Status = "Prod" (shipped to production)
#
# Advancement is MONOTONIC: a card is never moved backward. Back-merges and
# fast-forwards re-push commits that already shipped further down the line
# (staging->develop back-merge, develop==main sync); those cards keep their
# furthest Status instead of being "un-shipped" on the board.
#
# The "Ready for prod" intermediate state is set manually via /fr-pass at staging
# (drag-and-drop on the kanban or via a /fr-pass comment) when the FR reviewer
# declares the validation passed but the deploy hasn't happened yet.
#
# -- Per-repo override via `.kanban.yml` --
# A repo whose default branch is not the prod-truth (a deploy that ships from
# staging and never touches main) drops a `.kanban.yml` at its root:
#
# # .kanban.yml
# branch_status_map:
# staging: Prod # this repo's deploy ships staging-built artifacts
#
# It merges with the default mapping; keys are branch names, and a VALUE MUST BE
# A STATUS `ENV_FOR_STATUS` DECLARES in `scripts/branch_status_map.py` (the tools
# checkout below) -- one outside it is REFUSED and the card keeps its Status
# ((internal ref); the mapper's selftest holds the rule and the refusal).
on:
push:
branches: [develop, staging, master, main]
permissions:
contents: read
jobs:
advance:
name: advance / advance
# A branch CREATION or deletion is not a merge — its commits are inherited,
# not newly shipped. Without this guard, creating a branch (BEFORE = zero
# hash) falls through to the "last 50 commits" range below and mass-advances
# ~50 recent PRs' kanban items — e.g. every time a new `staging` branch is
# cut for a repo ((internal ref) D8, (internal ref)). Skip create/delete pushes.
if: github.event.created != true && github.event.deleted != true
runs-on: ubuntu-slim # runner: hosted (ubuntu-slim)
# Declared because `ubuntu-slim` kills at its 15-minute cap with no timeout
# report ((internal ref) item 2; scripts/runner-tier.py asserts it for every
# slim job). Measured: advance / advance: n=25, 25 s median, 110 s max.
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
# The mapping is read from this repo's tools checkout, not from a copy in
# this file: one definition of branch -> Status ((internal ref)).
- name: Mint a read-only token for org-config (private; the caller's own token cannot read it)
id: org-config-token
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }}
private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: org-config
permission-contents: read
# THE TOOLS REF ((internal ref) D4, (internal ref)): `job.workflow_sha` is the commit
# of THIS reusable file, so the scripts below are the ones it was reviewed
# with; `tools-ref` overrides it for a test. An EMPTY ref would check out the
# tools repo's DEFAULT branch fleet-wide, so it is refused. `toJSON(job)` because
# actionlint's `job` schema (1.7.12) does not know `workflow_sha`.
- name: Resolve the tools ref, refusing an empty one
id: tools-ref
env:
TOOLS_REF: ${{ 'main' }}
JOB_CONTEXT: ${{ toJSON(job) }}
run: |
if [ -z "$TOOLS_REF" ]; then
case "$JOB_CONTEXT" in
*'"workflow_sha"'*) TOOLS_REF="${JOB_CONTEXT#*\"workflow_sha\": \"}"; TOOLS_REF="${TOOLS_REF%%\"*}" ;;
esac
fi
if ! [[ "$TOOLS_REF" =~ ^[0-9a-f]{40}$ || ( -n "$TOOLS_REF" && "$TOOLS_REF" != *[![:alnum:]._/-]* ) ]]; then
echo "::error::the tools ref is empty or not a ref (${TOOLS_REF:-<empty>}); an empty ref would check out the tools repo's default branch instead of the commit this workflow was pinned at. Refusing."
exit 1
fi
echo "ref=$TOOLS_REF" >> "$GITHUB_OUTPUT"
echo "tools ref: $TOOLS_REF"
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: tracebloc/org-config
token: ${{ steps.org-config-token.outputs.token }}
ref: ${{ 'main' }}
path: .org-config-tools
persist-credentials: false
# PyYAML FOR A STAGING PUSH ONLY ((internal ref)): the mapper reads the repo's
# `staging_surface` through the inventory's one loader, which needs it, and a
# develop or main push reads no row. A failed install fails the mapper, and so
# the run: a push has no competing automation, so the card keeps what it had and
# a re-run once PyPI answers is the remedy (see the mapper's `main`).
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
if: github.ref_name == 'staging'
with:
python-version: '3.12'
- name: Install PyYAML for the inventory loader
if: github.ref_name == 'staging'
run: python -m pip install --quiet --disable-pip-version-check 'pyyaml==6.0.2'
- name: Resolve deploy env + status (one shared mapping)
id: env
env:
BRANCH: ${{ github.ref_name }}
# The workflow token, not the App token: this reads `.kanban.yml`
# from the caller's own repo, which is `contents: read` on itself.
GH_TOKEN: ${{ github.token }}
# ONE DEFINITION, SHARED WITH THE CLOSURE ROUTER ((internal ref)). This step
# used to hold its own `case` plus its own `yq` read of `.kanban.yml`, and
# the router held two more copies that ignored the override entirely -- so
# with a `.kanban.yml` present the two workflows wrote DIFFERENT statuses
# for the same merge and run ordering decided which stuck.
# Third argument is the REF to read `.kanban.yml` from, which is the branch
# being mapped -- not the repo default (a Bugbot finding, (internal ref)).
run: python3 .org-config-tools/scripts/branch_status_map.py "$BRANCH" "$GITHUB_REPOSITORY" "$BRANCH"
- name: Skip if branch not tracked
if: steps.env.outputs.env == ''
env:
BRANCH: ${{ github.ref_name }}
run: |
echo "Branch '$BRANCH' is not develop/staging/master/main (and no .kanban.yml override) - nothing to do."
# Authenticates as the tracebloc-release-train App ((internal ref): one shared
# user PAT's 5,000/hr was exhausted under this very workflow), ORG-scoped by
# `owner:` (a repo-scoped token cannot write an org ProjectV2), with no
# fallback to a PAT (it would let a broken App path look like a working
# migration). Minted BEFORE the extract step, its first consumer
# ((internal ref)): `/commits/{sha}/pulls` needs `pull-requests: read`, which
# the caller's restricted `github.token` lacks on a private repo, and a
# called workflow can only DOWNGRADE that token -- so the fix is this token,
# not a `permissions:` block. Gated on a tracked branch: an untracked push
# mints nothing.
- name: Mint an installation token
id: app-token
if: steps.env.outputs.env != ''
uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0
with:
app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }}
private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
# LEAST PRIVILEGE, DERIVED FROM THIS JOB'S OWN CALLS ((internal ref)) --
# the union of what the steps below need:
#
# organization(login:).projectV2 field/option lookup projects read
# updateProjectV2ItemFieldValue projects WRITE
# repository.pullRequest(n).projectItems PRs read
# GET /repos/{r}/commits/{sha}/pulls (extract step) PRs read
# contents read
#
# `contents: read` IS FOR THE EXTRACT STEP'S `/pulls` CALL, NOT THE
# MAPPER'S ((internal ref)): the mapper runs BEFORE this mint under
# `github.token`, so it was never a reason to grant contents
# ((internal ref)) -- read the call site, not the prose, before
# dropping or widening a scope. `repositories:` stays unnarrowed:
# `organization-projects` is an org-level grant either way, and narrowing
# is a token-scope change that wants its own measurement. No `issues`
# scope: nothing here touches an issue since (internal ref). Scopes are
# what the calls DOCUMENT, not what a run has proven -- an under-scoped
# token fails at the call, as a card left behind its shipped code.
permission-contents: read
permission-pull-requests: read
permission-organization-projects: write
- name: Extract PR numbers from new commits
id: prs
if: steps.env.outputs.env != ''
env:
BEFORE: ${{ github.event.before }}
SHA: ${{ github.sha }}
# The App token minted ABOVE, not `github.token`: `/commits/{sha}/pulls`
# needs `pull-requests: read` + `contents: read`, and the restricted
# workflow token lacks the former on a private repo ((internal ref)).
GH_TOKEN: ${{ steps.app-token.outputs.token }}
# DERIVE each commit's PR from GitHub, not from its subject text
# ((internal ref): the `(#N)` slot carries a ticket as often as a PR). The
# script falls back to the subject only when the API is empty, with a
# `::warning::`; it lives in the tools checkout above and `git log` runs
# against the caller's checkout (cwd). Selftest: scripts/tests/extract-advanced-prs-selftest.sh.
run: bash .org-config-tools/scripts/extract-advanced-prs.sh
- name: Update project fields for each PR
if: steps.env.outputs.env != '' && steps.prs.outputs.prs != ''
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
ORG: ${{ 'tracebloc' }}
PROJECT_NUMBER: ${{ 2 }}
# THE CANONICAL STAGE and THE OPTION NAMES IT MAY BE WRITTEN AS, preferred
# first ((internal ref)), from the mapper's DEPLOY_ENV_OPTION_NAMES: today
# each stage is written as its own name (`stg` for staging). `env` is the
# fallback for a mapper that predates them (a public copy runs the tools
# repo's `main`).
DEPLOY_ENV: ${{ steps.env.outputs.stage || steps.env.outputs.env }}
DEPLOY_ENV_NAMES: ${{ steps.env.outputs.env_names }}
STATUS_NAME: ${{ steps.env.outputs.status_name }}
REPO_FULL: ${{ github.repository }}
PR_NUMBERS: ${{ steps.prs.outputs.prs }}
DRY_RUN: ${{ false }}
run: |
set -euo pipefail
REPO_NAME="${REPO_FULL#*/}"
# selftest:graphql-read-start (the ONE line every GraphQL step carries; the function is scripts/lib/graphql-read.sh)
. .org-config-tools/scripts/lib/graphql-read.sh
# selftest:graphql-read-end
# Look up project ID + relevant field/option IDs (one query)
# A FAILURE ON GITHUB'S SIDE IS TRIED AGAIN ((internal ref)): this read is
# idempotent, so up to 3 attempts, exactly as the router's Status write
# ((internal ref)). `graphql_transient` names the only failures that earn
# one; anything else -- or the last attempt -- fails the step as before.
PROJ_ERRF=$(mktemp)
for _p in 1 2 3; do
_prc=0
# shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal
PROJ=$(gh api graphql -f query='
query($org: String!, $num: Int!) {
organization(login: $org) {
projectV2(number: $num) {
id
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField { id name options { id name } }
}
}
}
}
}' -F org="$ORG" -F num="$PROJECT_NUMBER" 2>"$PROJ_ERRF") || _prc=$?
if [ "$_p" -lt 3 ] && graphql_transient "$PROJ $(cat "$PROJ_ERRF")"; then
echo "::warning::project #$PROJECT_NUMBER field read met a GitHub-side error (attempt $_p/3), retrying: $(cat "$PROJ_ERRF") $PROJ"
sleep $((_p * 5))
continue
fi
[ "$_prc" -eq 0 ] || { echo "::error::project #$PROJECT_NUMBER field read failed (attempt $_p/3): $(cat "$PROJ_ERRF") $PROJ" >&2; exit 1; }
reject_graphql_errors "$PROJ" "project #$PROJECT_NUMBER field read" || exit 1
break
done
PROJECT_ID=$(echo "$PROJ" | jq -r '.data.organization.projectV2.id')
DEPLOY_FIELD=$(echo "$PROJ" | jq -r '.data.organization.projectV2.fields.nodes[]
| select(.name=="Deploy environment") | .id')
# THE OPTION BY THE MAPPER'S NAMES ((internal ref), (internal ref) S5). The staging
# stage's canonical value is `stg`, and so is the board's option since its
# 2026-10-15 rename; the old spelling `staging` is no longer accepted. The
# FIRST name in DEPLOY_ENV_NAMES the board has wins, so a future option
# rename needs only a mapper entry, no edit here. The names and their order
# are the mapper's; this only picks the first present, and none present is a
# refusal. A mapper with no `env_names` (older than this) names one, `env`.
if [ -z "${DEPLOY_ENV_NAMES:-}" ]; then
DEPLOY_ENV_NAMES=$(jq -cn --arg e "$DEPLOY_ENV" '[$e]')
fi
DEPLOY_PICK=$(echo "$PROJ" | jq -r --argjson names "$DEPLOY_ENV_NAMES" '
[.data.organization.projectV2.fields.nodes[] | select(.name=="Deploy environment")
| .options[] | select(.id != null)] as $o
| first($names[] as $n | $o[] | select(.name == $n) | "\(.id)\t\(.name)") // empty')
DEPLOY_OPT="${DEPLOY_PICK%%$'\t'*}"
DEPLOY_OPT_NAME="${DEPLOY_PICK#*$'\t'}"
STATUS_FIELD=$(echo "$PROJ" | jq -r '.data.organization.projectV2.fields.nodes[]
| select(.name=="Status") | .id')
STATUS_OPT=$(echo "$PROJ" | jq -r --arg s "$STATUS_NAME" '.data.organization.projectV2.fields.nodes[]
| select(.name=="Status") | .options[] | select(.name==$s) | .id')
if [ -z "$DEPLOY_OPT" ] || [ "$DEPLOY_OPT" = "null" ]; then
echo "Could not resolve Deploy environment option for '$DEPLOY_ENV' (tried $DEPLOY_ENV_NAMES) - aborting"
exit 1
fi
# FAIL CLOSED on an unresolvable Status option (Bugbot on (internal ref)):
# a graceful degrade here is a board that stops advancing behind green
# runs. A column rename, a project renumbering or a `.kanban.yml` naming
# a missing column are misconfigurations, cheaper as a red run than as
# weeks of un-advanced cards; the router aborts on the same condition.
if [ -z "$STATUS_FIELD" ] || [ "$STATUS_FIELD" = "null" ] \
|| [ -z "$STATUS_OPT" ] || [ "$STATUS_OPT" = "null" ]; then
echo "::error::Could not resolve Status option '$STATUS_NAME' in project #$PROJECT_NUMBER." \
"NOTHING WAS WRITTEN: this check runs before any field update, so no card" \
"was touched and there is no half-applied state to repair." \
"Check the board's Status column names against this workflow's branch map." >&2
exit 1
fi
# Now always 0 -- the branch that set it to 1 aborts instead. Kept so the
# two `[ "$SKIP_STATUS" != "1" ]` guards below stay valid without
# re-indenting their blocks; there is no live skip path.
SKIP_STATUS=0
# Pipeline order. Advancement is MONOTONIC -- a push only ever moves a
# card FORWARD, because back-merges and fast-forwards re-push commits
# that already shipped (seen live: (internal ref) to (internal ref)
# and (internal ref)). THE ORDER ITSELF lives in `scripts/pipeline_rank.sh`
# in the checkout above, the one declaration set-pr-status.yml shares
# ((internal ref); pipeline-rank-selftest.py holds every column of it).
# THE UNKNOWN POLICY IS THIS WORKFLOW'S: the table refuses a name it does
# not know rather than ranking it below everything -- which is how `Done`
# and `Cancelled` were demoted before they were ranked ((internal ref)) --
# and an unplaceable Status skips that ITEM and fails the step at the end.
RANK_TOOL=".org-config-tools/scripts/pipeline_rank.sh"
rank() { bash "$RANK_TOOL" "$1"; }
if ! TARGET_RANK=$(rank "$STATUS_NAME"); then
echo "::error::Status '$STATUS_NAME' has no place in the pipeline order," \
"so no card can be shown to be advancing rather than demoting." \
"NOTHING WAS WRITTEN." >&2
exit 1
fi
# One bad item must not abort the whole push: an unguarded mutation under
# `set -e` would leave every later PR un-advanced, so a per-item error is
# logged, flags the run, and the loop goes on.
RUN_FAILED=0
# A FAILED LOOKUP IS NOT AN ABSENT CARD (Bugbot on (internal ref)): a card
# this run could not READ is counted apart from one it could not write;
# one 403 must not strand the PRs after it (kanban-item-lookup-selftest.py).
LOOKUP_FAILED=0
LOOKUP_ERRF=$(mktemp)
update_field() { # $1=fieldId $2=optionId $3=human label
local err body _u _urc
if [ "${DRY_RUN:-false}" = "true" ]; then
echo "[DRY] would set $3 on item $ITEM_ID"
return 0
fi
# THE WRITE'S BODY IS READ TOO: an exit-0 mutation response carrying
# errors[] means the field was NOT written ((internal ref); same rule as the reads).
# A FAILURE ON GITHUB'S SIDE IS TRIED AGAIN ((internal ref)): setting a
# single-select value is idempotent, so up to 3 attempts, as the router's
# Status write; only what `graphql_transient` names earns one.
for _u in 1 2 3; do
_urc=0
# shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal
body=$(gh api graphql -f query='
mutation($p: ID!, $i: ID!, $f: ID!, $o: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $p, itemId: $i, fieldId: $f,
value: {singleSelectOptionId: $o}
}) { projectV2Item { id } }
}' -F p="$PROJECT_ID" -F i="$ITEM_ID" -F f="$1" -f o="$2" 2>&1) || _urc=$?
if [ "$_u" -lt 3 ] && graphql_transient "$body"; then
echo "::warning::#$prnum $3 write met a GitHub-side error (attempt $_u/3), retrying: $body"
sleep $((_u * 5))
continue
fi
break
done
if [ "$_urc" -eq 0 ]; then
if err=$(reject_graphql_errors "$body" "#$prnum $3 write" 2>&1); then
return 0
fi
err="${err#::error::}"
else
err="$body"
fi
# ARCHIVED BETWEEN THE LOOKUP AND THIS WRITE ((internal ref)): a card can
# be archived concurrently with this push's run (the daily kanban-archive,
# or a person), so the `isArchived` skip below can read a live
# card that GitHub then refuses to update. That refusal is the
# pre-check's own answer, arrived late -- out of the deploy flow, a
# clean skip -- and the card's remaining writes are skipped with it.
# Only this one message; every other refusal is still a failed write.
if grep -qF 'The item is archived and cannot be updated' <<< "$body"; then
echo "::notice::#$prnum project item was archived between lookup and write -- $3 not written, skipping (out of deploy flow)"
ITEM_ARCHIVED=1
return 0
fi
echo "::warning::#$prnum $3 update failed: $err"
RUN_FAILED=1
return 0
}
for prnum in $PR_NUMBERS; do
# THE LOOKUP'S FAILURE IS READ, NOT REPLACED, AND THE BODY DECIDES,
# WHATEVER THE EXIT CODE (Bugbot on (internal ref); (internal ref)): a rate
# limit, a 403 or an exit-0 `errors[]` payload used to read as "not on
# project" in a GREEN run. A failed read is a could-not-tell, recorded
# as one. ONE shape is benign -- a number that is not a pull request
# (the extract step's subject fallback can name an issue) comes back
# as NOT_FOUND on exactly `repository.pullRequest` -- and only that is a
# quiet skip ((internal ref)); everything else fails closed
# (kanban-item-lookup-selftest.py holds every shape).
# A FAILURE ON GITHUB'S SIDE IS TRIED AGAIN ((internal ref)): a read is
# idempotent, so up to 3 attempts; only what `graphql_transient` names
# earns one, and the LAST attempt's answer is classified below as before.
for _l in 1 2 3; do
LOOKUP_RC=0
# shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal
RESP=$(gh api graphql -f query='
query($org: String!, $repo: String!, $num: Int!) {
repository(owner: $org, name: $repo) {
pullRequest(number: $num) {
projectItems(first: 10) {
nodes {
id isArchived project { number }
status: fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { name }
}
}
}
}
}
}' -F org="$ORG" -F repo="$REPO_NAME" -F num="$prnum" 2>"$LOOKUP_ERRF") || LOOKUP_RC=$?
if [ "$_l" -lt 3 ] && graphql_transient "$RESP $(cat "$LOOKUP_ERRF")"; then
echo "::warning::#$prnum project-item lookup met a GitHub-side error (attempt $_l/3), retrying: $(cat "$LOOKUP_ERRF") $RESP"
sleep $((_l * 5))
continue
fi
break
done
if jq -e '(.errors // []) | length > 0 and all(.type == "NOT_FOUND" and .path == ["repository", "pullRequest"])' <<<"$RESP" >/dev/null 2>&1; then
echo "::notice::#$prnum is not a pull request in $REPO_FULL (NOT_FOUND) -- skipping"
continue
fi
if [ "$LOOKUP_RC" -ne 0 ] || ! reject_graphql_errors "$RESP" "#$prnum project-item lookup" 2>>"$LOOKUP_ERRF"; then
LOOKUP_ERR=$(sed 's/^::error:://' "$LOOKUP_ERRF" | tr '\n' ' ')
echo "::error::#$prnum project-item lookup FAILED -- its board state is UNKNOWN, not absent, and the card was NOT advanced: ${LOOKUP_ERR:-gh exited non-zero with no message}"
LOOKUP_FAILED=$((LOOKUP_FAILED + 1))
continue
fi
ITEM_ID=$(echo "$RESP" | jq -r --arg n "$PROJECT_NUMBER" '.data.repository.pullRequest.projectItems.nodes[]?
| select(.project.number == ($n | tonumber)) | .id' 2>/dev/null | head -1)
if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then
echo "#$prnum not on project (or not a PR) - skipping"
continue
fi
# An archived project item cannot be updated: the mutation errors and,
# under `set -e`, used to abort the whole step -- stranding every PR
# after it in the push below its deploy column. Archived cards are
# intentionally out of the deploy flow, so skip them cleanly.
ARCHIVED=$(echo "$RESP" | jq -r --arg n "$PROJECT_NUMBER" '.data.repository.pullRequest.projectItems.nodes[]?
| select(.project.number == ($n | tonumber)) | .isArchived' 2>/dev/null | head -1)
if [ "$ARCHIVED" = "true" ]; then
echo "::notice::#$prnum project item is archived -- skipping (out of deploy flow)"
continue
fi
CURRENT_STATUS=$(echo "$RESP" | jq -r --arg n "$PROJECT_NUMBER" '.data.repository.pullRequest.projectItems.nodes[]?
| select(.project.number == ($n | tonumber)) | .status.name // ""' 2>/dev/null | head -1)
# NO Status AT ALL RANKS BELOW EVERYTHING, and that is not the
# unknown case: an empty value is a card the board has not placed
# yet, and writing one is what the "no card sits at No Status"
# invariant depends on. A NON-EMPTY name the order cannot place is
# the unknown case, and it refuses (see RANK_TOOL above).
CURRENT_RANK=0
if [ -n "$CURRENT_STATUS" ] && ! CURRENT_RANK=$(rank "$CURRENT_STATUS"); then
echo "::error::#$prnum sits in '$CURRENT_STATUS', which has no place in the pipeline order, so Status=$STATUS_NAME cannot be shown to be an ADVANCE -- the card was NOT touched"
RUN_FAILED=1
continue
fi
if [ "$CURRENT_RANK" -ge "$TARGET_RANK" ]; then
echo "::notice::#$prnum already at '${CURRENT_STATUS:-none}' (>= '$STATUS_NAME') -- not demoting"
continue
fi
echo "-> PR #$prnum: Deploy env = $DEPLOY_ENV (option '$DEPLOY_OPT_NAME')"
ITEM_ARCHIVED=0
update_field "$DEPLOY_FIELD" "$DEPLOY_OPT" "Deploy env=$DEPLOY_OPT_NAME"
if [ "$SKIP_STATUS" != "1" ] && [ "$ITEM_ARCHIVED" = "0" ]; then
echo "-> PR #$prnum: Status = $STATUS_NAME"
update_field "$STATUS_FIELD" "$STATUS_OPT" "Status=$STATUS_NAME"
fi
done
# NO ISSUE ADVANCEMENT HERE. This workflow advances PRs only: a completed
# issue goes straight to `Done` in kanban-closure-router.yml ((internal ref)),
# which is terminal and archived daily, so nothing is left to drift --
# the answer to (internal ref), whose remedy (marching the card through the
# deploy columns) this block would undo if kept. What the removed loop
# did, should issue advancement ever be needed again:
# the removed loop resolved closingIssuesReferences CROSS-REPO, failed CLOSED
# on a lookup error ((internal ref)), skipped a non-PR number quietly
# ((internal ref)) and refused a still-OPEN closing issue ((internal ref)).
# BOTH COUNTERS FAIL THE STEP, after every PR in the push has had its
# turn. A red run is the only signal a stranded card gets; advancement
# is monotonic, so re-running this workflow once the API error has
# cleared is safe and is the remedy.
if [ "$LOOKUP_FAILED" -ne 0 ]; then
echo "::error::$LOOKUP_FAILED PR lookup(s) FAILED (see the errors above) -- those cards may sit behind their shipped code; re-run this workflow once the API error has cleared."
fi
if [ "$RUN_FAILED" -ne 0 ]; then
echo "::error::One or more project items failed to update (see warnings above)."
fi
if [ "$LOOKUP_FAILED" -ne 0 ] || [ "$RUN_FAILED" -ne 0 ]; then
exit 1
fi