From dbab16fd07fb56b943b837148cafc58318977a42 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:41:59 +0200 Subject: [PATCH 01/12] chore(onboard): add .github/workflows/advance-deploy-env.yml (content profile) --- .github/workflows/advance-deploy-env.yml | 523 +++++++++++++++++++++++ 1 file changed, 523 insertions(+) create mode 100644 .github/workflows/advance-deploy-env.yml diff --git a/.github/workflows/advance-deploy-env.yml b/.github/workflows/advance-deploy-env.yml new file mode 100644 index 0000000..c74f523 --- /dev/null +++ b/.github/workflows/advance-deploy-env.yml @@ -0,0 +1,523 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/advance-deploy-env.yml` +# (blob 3656a2eaa46555c157af7f713ec7d25f56aa487f) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/advance-deploy-env-caller.yml` (blob a4796127e8f8ed55464d9bc51b2271a05638712e), 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 tracebloc/org-config. 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 org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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) +# 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` -- +# For repos where the default branch isn't the prod-truth (e.g. averaging-service +# deploys a Docker image from staging without ever touching main), drop a +# `.kanban.yml` at the repo root: +# +# # .kanban.yml +# branch_status_map: +# staging: Prod # this repo's deploy ships staging-built artifacts +# +# The override merges with the default mapping. Anything you don't explicitly +# remap stays on the default. Keys are branch names. +# +# VALUES MUST BE A STATUS `ENV_FOR_STATUS` DECLARES, in +# tracebloc/.github's `scripts/branch_status_map.py` -- read the list there rather +# than from a copy here. The three names this comment used to list were a copy, and +# the table has five. +# A value outside it is REFUSED (backend#2324): the mapper exits non-zero, this +# workflow fails red and the card keeps whatever Status it had. It used to be +# accepted and passed straight to the board write, where it resolves to no option +# id -- and in the closure router that no-write let the project's built-in +# "Item closed" automation set `Cancelled` and archive the card within a day. + +on: + push: + branches: [develop, staging, master, main] + +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 (RFC-BACKEND-0008 D8/#1274). Skip create/delete pushes. + if: github.event.created != true && github.event.deleted != true + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + # The mapping comes from .github, not from a copy in this file: one + # definition of branch -> Status (backend#2243). + - 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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tracebloc/org-config + token: ${{ steps.org-config-token.outputs.token }} + ref: main + path: .kanban-map + persist-credentials: false + + - 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 (backend#2243). 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 (Bugbot, .github#295). + run: python3 .kanban-map/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." + + # THIS is the workflow backend#2036 was filed about. On 2026-08-14 at 06:22 + # UTC, run 31776053792 on client-runtime died with + # gh: API rate limit already exceeded for user ID 54042461 + # exit 1 -- because every board caller in the fleet, both crons, the + # conformance gate and that person's own `gh` shared ONE user PAT's 5,000/hr. + # The workflow failed CLOSED, which is right, and the cost was still a card + # left behind its own shipped code until someone noticed. + # + # An installation token has a budget that is not shared with a human's + # interactive use and does not evaporate when that person rotates a token or + # leaves. `owner:` makes it ORG-scoped; a repo-scoped token cannot write an + # org ProjectV2. + # + # Minted BEFORE the extract step, because the extract step is its first + # consumer (backend#3447): `/commits/{sha}/pulls` needs `pull-requests: read`, + # and the caller's `github.token` runs under the org default of restricted + # `read` -- contents and packages only -- so under that token the read 403s + # on every PRIVATE repo and succeeds on every public one. From .github#438 + # reaching `main` (2026-09-08 19:10 UTC) until this change, that was 100 % of + # private-repo pushes: the fail-closed branch below did its job and no card + # moved. A called workflow can only DOWNGRADE the caller's token, so the fix + # is not a `permissions:` block here -- it is this token, which already + # carries the grant. Gated on a tracked branch only: a push to an untracked + # branch still mints nothing. + # + # NO FALLBACK TO THE PAT: a fallback would let a broken App path keep + # working silently, so the migration would look complete while nothing had + # migrated (backend#1680's whole subject). + - name: Mint an installation token + id: app-token + if: steps.env.outputs.env != '' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 (backend#2157). + # Without any `permission-*` the token carried the App's FULL installation + # grant -- contents:write included -- across every installed repo. The + # step below makes exactly five kinds of call, and this is the union of + # what they 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 + # + # The ONE repository-CONTENT read is the extract step's + # `/commits/{sha}/pulls`, so contents stays at `read` -- see the call-site + # block below before dropping it. Neither checkout above is on this token + # (both use `github.token` / `persist-credentials: false`), so contents + # stops at read -- and administration/actions/checks read still drop. + # + # `repositories:` STAYS UNNARROWED, but the reason it used to give is gone. + # It cited the closing-issue advancement, which was deliberately cross-repo; + # backend#2722 removed that block, so nothing here reaches outside the + # calling repo any more. `organization-projects` is an ORG-level grant and is + # not narrowed by `owner:` alone either way, so leaving this unnarrowed costs + # nothing measurable -- narrowing it is a plausible tidy-up, but it is a token + # -scope change that wants its own measurement rather than riding a + # behavioural fix. Do not re-cite the closing-issue block as the reason. + # + # WHAT IS NOT PROVEN HERE. These are the scopes the calls DOCUMENT a need + # for, not scopes a run has exercised. An under-scoped token does not fail + # at mint time -- it fails at the call, which for this workflow means a + # card left behind its own shipped code. The first push to a tracked + # branch carrying a PR is the real test; if it reddens, read the failing + # call rather than widening the list back to a full grant. + # `contents: read` IS FOR THE EXTRACT STEP'S CALL SITE, NOT THE MAPPER'S + # (backend#3447). Read this before dropping it again. + # + # saadqbal's finding on .github#324 was narrow and is STILL CORRECT, by its + # own test -- check the call site, not the prose. `contents: read` reached + # this file by analogy with kanban-closure-router, whose mapper calls run + # AFTER its mint under the App token; this workflow's ONE mapper call is + # `:94`, in the `env` step, which runs BEFORE this mint and under + # `GH_TOKEN: ${{ github.token }}`. It also maps `$GITHUB_REPOSITORY`, its + # own repo, where the router maps `$REPO_FULL`, the caller's. Same script, + # opposite side of the mint: the scope was required there and inert here. + # That reasoning is untouched, and the mapper is still not a reason to + # grant contents. Do not re-cite it as one. + # + # WHAT CHANGED IS A NEW CALL SITE ON THIS TOKEN. .github#438 put + # `GET /repos/{r}/commits/{sha}/pulls` in the extract step, and this PR + # moves that step to AFTER the mint -- so by the same call-site test its + # scope is now this token's business, which it was not when #324 was + # written. `fr-gate.yml`'s mint comment ("NOT DERIVED FROM A TEMPLATE") + # records that endpoint as `contents: read + pull-requests: read`, derived + # from its own calls under backend#2157 and running as a required check on + # every promotion since; no workflow in this repo reads that endpoint under + # `pull-requests: read` alone. Granting only PRs read + # would reproduce backend#3447 one scope over, in the same silent shape: + # the read 403s, the fail-closed branch refuses the subject fallback, and + # no card moves on any private repo. + # + # `permission-issues` DROPPED (backend#2722). The only issue reads were + # `repository.issue(n) {state, projectItems}` in the removed closing-issue + # block; with it gone, nothing in this workflow touches an issue. Re-add it + # only alongside a call that needs it. + 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 org-default + # restricted workflow token holds only the latter on a private repo + # (backend#3447; the mint step's comment has the derivation). + GH_TOKEN: ${{ steps.app-token.outputs.token }} + # DERIVE each commit's PR from GitHub, not from its subject text + # (backend#3365). The org convention `type(scope): summary (backend#N)` + # puts a ticket in the `(#N)` slot, and an edited squash subject may carry + # no `(#N)` at all -- so the old subject-grep attributed the wrong card or + # none (2/48 on the 2026-09-07 staging hop). The script GETs each commit's + # merged PR and falls back to the subject only when the API is empty, with a + # `::warning::` so an unattributed commit is visible. It lives in .github + # (checked out at .kanban-map above), one definition; `git log` runs against + # the caller's checkout (cwd). Selftest: scripts/tests/extract-advanced-prs-selftest.sh. + run: bash .kanban-map/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 }} + DEPLOY_ENV: ${{ steps.env.outputs.env }} + 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#*/}" + + # Look up project ID + relevant field/option IDs (one query) + # 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") + + 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') + DEPLOY_OPT=$(echo "$PROJ" | jq -r --arg e "$DEPLOY_ENV" '.data.organization.projectV2.fields.nodes[] + | select(.name=="Deploy environment") | .options[] | select(.name==$e) | .id') + 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' - aborting" + exit 1 + fi + + # Status resolution does NOT degrade gracefully -- see the abort below. + # It used to, and that was the bug: a graceful degrade here means the + # board stops advancing while every run reports success. + # FAIL CLOSED. This used to warn and set SKIP_STATUS=1, so an unresolvable + # Status option meant the run stayed GREEN while no card advanced -- the + # board silently stops tracking the pipeline and the only signal is a + # warning nobody reads (Bugbot, .github#243, High). + # + # It is also the ONE asymmetry in this file and its siblings: the Deploy + # environment lookup twelve lines above aborts, and + # kanban-closure-router.yml aborts on exactly this condition. A column + # rename, a project renumbering or a `.kanban.yml` naming a column that + # does not exist are all misconfigurations, and every one of them is + # cheaper to find as a red run than as three weeks of un-advanced cards. + 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 (mirrors fr-gate's rank). Advancement is monotonic: + # a push only ever moves a card FORWARD. Pushes routinely carry commits + # that already shipped further down the pipeline -- a staging->develop + # back-merge re-pushes Prod-shipped PRs to develop, a develop==main + # fast-forward does the same -- and demoting those cards would un-ship + # them on the board (seen live: .github#87-#92 and #95). + rank() { + case "$1" in + "Backlog") echo 1 ;; + "North Stars") echo 2 ;; + "Ready") echo 3 ;; + "In progress") echo 4 ;; + "Code review") echo 5 ;; + "On dev") echo 6 ;; + # RANK 7: the agent stage, between `On dev` and human FR. READ-ONLY for + # now -- nothing writes this value yet (#1578 does that, in a LATER + # hop). An unknown Status returns "" here, the guard below fails, and + # evaluation falls through to strict equality: the card BLOCKS every + # prod promotion carrying it. That is the backend#1411 shape, and the + # column already EXISTS on the board, so this was a live landmine + # waiting for the first card to land in it (#1577, RFC-BACKEND-1552 D5). + "Staging (agent review)") echo 7 ;; + # `Staging (human review)` IS GONE (saadqbal on .github#295). It ranked + # here as a shim so the monotonic guard stayed stable across the #1592 + # rename INSTANT -- and that instant has passed: measured against project + # #2, whose Status options are Backlog, North Stars, Ready, In progress, + # Code review, On dev, Staging (agent review), FR on staging, Ready for + # prod, Prod, Done, Cancelled. No card can carry the old name because the + # column does not exist. + # + # Keeping a shim for a completed rename is how this same file came to + # accept an override value naming a nonexistent column -- the headline + # finding of this PR. Retiring it is also what lets this file back into + # `kanban-columns-check.py`'s WRITERS: with the phantom gone, all twelve + # remaining names are live board options. + "FR on staging") echo 8 ;; + "Ready for prod") echo 9 ;; + "Prod") echo 10 ;; + # Done and Cancelled are TERMINAL: nothing may demote a card out of + # them. Both returned 0 here, which inverted the monotonic guard below + # so the next push carrying an old commit demoted a Done card -- and + # staging->develop back-merges re-carry old commits routinely + # (RFC-BACKEND-1405 D8, backend#1411). + "Done") echo 11 ;; + "Cancelled") echo 11 ;; + *) echo 0 ;; + esac + } + TARGET_RANK=$(rank "$STATUS_NAME") + + # Apply one single-select field update WITHOUT letting a single bad item + # abort the whole push. An unguarded mutation under `set -e` aborts the + # step on the first failure, leaving every later PR un-advanced. Archived + # items are already skipped above; this is defense-in-depth for any other + # per-item error -- log it, flag the run, but keep processing the rest. + RUN_FAILED=0 + # A FAILED LOOKUP IS NOT AN ABSENT CARD (Bugbot, org-config#3). Counted + # separately from RUN_FAILED because it is a different event: a card + # whose state this run could not READ, as opposed to one it read and + # could not write. The loop keeps going either way -- one 403 must not + # strand every PR after it in the push -- and the step fails at the end. + LOOKUP_FAILED=0 + LOOKUP_ERRF=$(mktemp) + update_field() { # $1=fieldId $2=optionId $3=human label + local err + if [ "${DRY_RUN:-false}" = "true" ]; then + echo "[DRY] would set $3 on item $ITEM_ID" + return 0 + fi + # shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal + if err=$(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 >/dev/null); then + 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. This used to be + # `... 2>/dev/null) || RESP='{}'`, so a rate limit, a 403 or a network + # error became an empty object, the item lookup below found nothing in + # it, and the card was "not on project - skipping" -- unmoved, behind + # its shipped code, in a run that stayed GREEN (Bugbot, org-config#3). + # A read that failed is a could-not-tell and is recorded as one. + # + # `gh api graphql` exits 1 for a GraphQL-level error too, and ONE of + # those is benign: a number that is not a pull request (the extract + # step falls back to the commit subject's `(#N)`, which can name an + # issue) comes back as `errors[].type == "NOT_FOUND"` with the body + # still on stdout. That -- and only that -- is a quiet skip, the same + # answer the removed closing-issue loop gave (.github#181). Anything + # else is a failed read: `errors` of any other type, an HTTP error body + # with no `errors` at all, or no body -- the classification is + # whitelist, so an unfamiliar shape fails closed. + # shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal + if ! 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"); then + LOOKUP_ERR=$(tr '\n' ' ' <"$LOOKUP_ERRF") + if jq -e '(.errors // []) | length > 0 and all(.type == "NOT_FOUND")' <<<"$RESP" >/dev/null 2>&1; then + echo "::notice::#$prnum is not a pull request in $REPO_FULL (NOT_FOUND) -- skipping" + continue + fi + 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) + if [ "$TARGET_RANK" -gt 0 ] && [ "$(rank "$CURRENT_STATUS")" -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" + update_field "$DEPLOY_FIELD" "$DEPLOY_OPT" "Deploy env=$DEPLOY_ENV" + + if [ "$SKIP_STATUS" != "1" ]; 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. + # + # It used to also advance the issues each promoted PR closes (backend#1600), + # because the closure router parked a PR-closed issue at `On dev` and nothing + # ever moved it when the code shipped -- it drifted permanently (2026-08-06: + # all 20 drifted cards were closed issues, 0 PRs). That was a real problem and + # #1600 was right to fix it; marching the card through the deploy columns was + # the wrong remedy. + # + # backend#2722 sends a completed issue straight to `Done` in + # kanban-closure-router.yml instead. `Done` is terminal, so there is nothing + # left to drift, and kanban-archive sweeps it off the board daily -- which + # answers #1600 more completely than advancing ever did. With no issue parked + # in a deploy column, this block had nothing left to advance; kept, it would + # pull those cards straight back in and undo the other half of the fix. + # + # Worth knowing what was deleted, because it was hard-won and should be + # reused rather than rewritten if issue advancement is ever needed again: + # the removed loop resolved closingIssuesReferences CROSS-REPO, failed CLOSED + # on a lookup error so a rate limit could not read as "closes no issues" + # (.github#166) while still skipping a non-PR number quietly (.github#181), + # and refused to advance a still-OPEN closing issue (.github#168) -- PRs merge + # to develop rather than the default branch, so GitHub does not auto-close + # them and the reference list is full of in-progress cards. + + # 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 From 181da96673767bcc80adfb4451ea3347c90d5db0 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:01 +0200 Subject: [PATCH 02/12] chore(onboard): add .github/workflows/bugbot-gate.yml (content profile) --- .github/workflows/bugbot-gate.yml | 179 ++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 .github/workflows/bugbot-gate.yml diff --git a/.github/workflows/bugbot-gate.yml b/.github/workflows/bugbot-gate.yml new file mode 100644 index 0000000..d7f5b09 --- /dev/null +++ b/.github/workflows/bugbot-gate.yml @@ -0,0 +1,179 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/bugbot-gate.yml` +# (blob c526c0a1f25316f429afbadb00ae1eaa4d0807bc) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/bugbot-gate-caller.yml` (blob f87fa0ed0dfd714ced846d5db9487f2bf9622cbe), 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 tracebloc/org-config. 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 +# `bugbot / ` prefix so the check contexts branch protection requires do +# not change. Scripts are still fetched from org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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: Bugbot review gate + +# Reusable workflow. Makes Cursor Bugbot's review a GATE instead of advice +# (tracebloc/backend#2284). `scripts/bugbot-gate.py` carries the measurements and +# the reasoning; this file is the wiring, and only says what the script cannot. +# +# THE SHORT VERSION OF WHY THIS IS NOT "ADD `Cursor Bugbot` TO THE REQUIRED +# CONTEXTS". Measured 2026-08-22: Bugbot emits `success` when clean and `neutral` +# when it has findings, and never `failure`. That makes requiring the context a +# dichotomy with two bad horns: +# +# * if `neutral` SATISFIES a required context -- GitHub's documented behaviour +# -- requiring it gates nothing, because `failure` never occurs; +# * if it does NOT, requiring it permanently bricks every PR that ever received +# a finding, because Bugbot re-runs only on a push or an explicit +# `bugbot run`, so resolving a finding can never turn the check green again +# -- and a promotion PR may not be pushed to at all. client#786 and +# frontend-app#863 both merged to `main` on 2026-08-21 carrying exactly that +# state (`neutral` on the merged head, one resolved Medium beneath). +# +# So the verdict is REPORTED here and the decision is derived from the threads. +# +# ARMING THIS IS THREE STEPS, IN THIS ORDER, AND THE ORDER IS THE POINT. +# +# 1. this file reaches `main`. Every caller in this org pins +# `tracebloc/.github/...@main` (RFC-BACKEND-1405 Q3), so a caller added +# before the reusable is ON main references something main does not have and +# dies with a `startup_failure` -- a red check on the very PR introducing a +# gate. code-quality-caller.yml records the identical sequencing for its +# `action-pins` input: the job went to develop in #159 and was armed only +# once `main` carried it. That is why the PR adding THIS file adds no caller. +# 2. a caller is added, starting with tracebloc/.github itself. The gate is +# ADVICE at this point, and saying so plainly is the whole of backend#2284. +# 3. the `bugbot / review` context is added to branch protection, once step 2 +# has been observed green on real PRs. +# +# Never the reverse. backend#1976's lesson is that a contract claiming a context +# reality lacks is itself a finding; and a required context no workflow reports +# leaves every PR waiting forever (client#665, pii-gate/pii-check). Arm while +# green, then let the contract claim it. +# +# The TEST tier needs none of that and is armed from the start: +# `scripts/tests/bugbot-gate-selftest.py` and `-mutations.py` both run inside +# `selftests`, ALREADY a required context on develop/staging/main -- the same +# trick selftests.yml uses to arm a guard with no branch-protection edit. +# +# NO `paths:` FILTER, and the caller must not add one. A required check that is +# path-filtered never reports on a PR the filter skips, so the PR waits at +# "Expected -- waiting for status" forever. This org has hit that twice +# (client#665, pii-gate/pii-check); code-quality.yml's and selftests.yml's +# headers both warn about it. +# +# WHAT A CALLER MUST TRIGGER ON: `pull_request` with at least +# `[opened, reopened, synchronize, ready_for_review]`. `ready_for_review` is +# load-bearing, not boilerplate -- this gate deliberately PASSES a draft (a draft +# cannot merge, and Bugbot's behaviour on drafts is not this gate's business), so +# leaving that type out means the exemption is never lifted and the gate is +# permanently green on a PR that was opened as a draft. +# +# THE TRIGGER THIS WANTS AND CANNOT HAVE YET. Resolving a Bugbot thread is not a +# `pull_request` event, so `pull_request_review_thread: [resolved]` is the right +# trigger for the severity half of this gate. actionlint 1.7.12 -- a REQUIRED +# check in tracebloc/.github, run with no config file and no `-ignore` on purpose +# -- does not know that event name and rejects the workflow, so no caller can +# carry it without landing a red required check. Measured against the pinned +# binary: zero occurrences of `review_thread`. +# +# The consequence is written into the failure message rather than left to be +# discovered: resolve the thread, then RE-RUN this check. Not "push a commit", +# which is what a stale gate otherwise teaches. Adding the trigger when +# actionlint learns the event is a follow-up on backend#2284. +# +# THIS JOB WAITS, on purpose. Its central claim is that Bugbot reviewed the +# CURRENT head, which is false for the first minutes after every push -- so it +# polls. Measured over 40 Bugbot runs: p50 164s, p90 332s, max 635s. The wait +# runs concurrently with Bugbot's own work, so it adds latency only when Bugbot +# is slower than usual. `timeout-minutes` is deliberately above the script's own +# budget so the script reports WHY it gave up instead of the runner killing it +# with no message. + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + +concurrency: + group: bugbot-gate-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# The CALLER must grant at least these three, or the run fails at startup with +# no jobs -- a called workflow cannot hold more than its caller. `checks: read` +# and `pull-requests: read` are both load-bearing: the first reads the head's +# check runs, the second reads the review threads. `contents: read` is for the +# checkout of the checker. +permissions: + contents: read + checks: read + pull-requests: read + +jobs: + review: + name: bugbot / review + runs-on: ubuntu-latest + # Above the script's own 1500s budget on purpose: the script must be the thing + # that reports a timeout, with the measured latencies in the message. A + # runner-level kill produces no explanation at all. + timeout-minutes: 30 + steps: + # Same shape as code-quality.yml and blocked-gate.yml: the checker lives + # here, not in the calling repo, so the reusable fetches it. This repo is + # PRIVATE (org-config replaced the public `.github` as the host on + # 2026-09-10) and the caller's `github.token` reads only the caller's own + # repo, so the checkout needs a token that can read org-config: a + # read-only App installation token, minted from the two secrets the + # caller passes by name (`on.workflow_call.secrets` above, Q5). + - 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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 + + - name: Check out the shared checker + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tracebloc/org-config + token: ${{ steps.org-config-token.outputs.token }} + ref: ${{ 'main' }} + path: .bugbot-gate-tools + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + # Nothing is pip-installed: the checker imports only the standard library + # and shells out to `gh`, which is preinstalled on the runner. Asserted + # rather than assumed -- the selftest imports the module and would fail on + # a missing import, and it runs with no pip step in `selftests.yml`. + - name: Bugbot review gate + env: + # `github.token`, not `secrets.inherit`: this needs only the scopes + # declared above, and inheriting would hand it every secret the caller + # holds for no gain (RFC-BACKEND-1405 Q5). + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + MIN_SEVERITY: ${{ 'high' }} + WAIT_SECONDS: ${{ 1500 }} + POLL_SECONDS: ${{ 20 }} + # No interpolation of any PR-controlled string into this shell: the + # checker reads everything it needs from the API itself, so a finding + # title or a branch name never transits a command line. + run: python3 .bugbot-gate-tools/scripts/bugbot-gate.py From 4647d20f3c28d62c40850bbe89cc7c2b80ff97d2 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:03 +0200 Subject: [PATCH 03/12] chore(onboard): add .github/workflows/code-quality.yml (content profile) --- .github/workflows/code-quality.yml | 748 +++++++++++++++++++++++++++++ 1 file changed, 748 insertions(+) create mode 100644 .github/workflows/code-quality.yml diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 0000000..4bf719a --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,748 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/code-quality.yml` +# (blob 50e3e61e2be49ed44f62d7896f11d93dad513a5e) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/code-quality-caller.yml` (blob d0d9d4c4ecb1747e9f1b3fadcf516b79fe8c55bb), 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 tracebloc/org-config. 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 +# `quality / ` prefix so the check contexts branch protection requires do +# not change. Scripts are still fetched from org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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: Code quality + +# Reusable workflow. The org's first *code* quality gate — every other reusable +# workflow in this repo automates process (kanban, FR gate, WIP), and none +# of them looks at what the code does. +# +# WHY (measured over 60 days of automated code review) +# The automated reviewer's false-positive rate is 3% — so what it reports is +# real, and it arrives at the most expensive possible moment. Of the findings +# we hand-classified, 20% were expressible as a lint or grep rule, and 14% +# were rules the team had already agreed on, being re-enforced one PR at a +# time by a reviewer instead of once by CI. This workflow moves that share to +# the left, where it costs seconds instead of a review round-trip. +# +# JOBS — each is a SEPARATE job on purpose +# ruff Python lint (opt in with `python: true`) +# shellcheck shell lint (opt in with `shell: true`) +# gitleaks credential scanning (on by default) +# house-rules the org's own grep-level rules (on by default) +# early-close the pipefail SIGPIPE gate (on by default, whole-tree) +# Separate jobs run independently, so a ruff failure never hides what +# shellcheck found. Steps inside one job would short-circuit; jobs do not. +# +# BLOCKING BEHAVIOUR — read this before adopting +# `soft-fail` defaults to TRUE: every finding is annotated on the diff and +# written to the job summary, and the job still exits 0. That is deliberate. +# A linter switched on as a required check against an unlinted backlog gets +# the check removed, not the backlog fixed. The intended path is: +# +# 1. Add the caller with the defaults below. Findings appear; nothing blocks. +# 2. Run it once with `all-files: true` to size the whole backlog. +# 3. Clear the backlog (or record a gitleaks baseline / add ignore pragmas +# for the deliberate exceptions). +# 4. Flip `soft-fail: false` in the caller. +# 5. Mark `Code quality / ` as a required status check in branch +# protection. Only then is it a gate. +# +# Step 4 is the point of the exercise. `soft-fail: true` is a migration +# setting, not a destination: a linter that only ever warns changes nothing. +# If a repo is still on the default months from now, that is the finding. +# +# SOFT-FAIL SOFTENS FINDINGS, NEVER A SCANNER THAT COULD NOT RUN. Every job +# treats exactly two exit codes as verdicts -- 0 (clean) and 1 (findings) -- +# and anything else (ruff 2, shellcheck 2-4, gitleaks >1, 126/127, a signal) +# fails the job with exit 2 whatever `soft-fail` says. A scan that produced no +# verdict has not passed; recording it as clean is how a gate turns into +# decoration (Bugbot on org-config#3, backend#3658; backend#1729 rule 3). +# +# ADOPTION — drop this in as `.github/workflows/code-quality-caller.yml` +# +# name: Code quality +# +# on: +# pull_request: +# types: [opened, reopened, synchronize, ready_for_review] +# +# # Supersede the previous run when a branch is pushed again. Measured: +# # workflows missing this stack ~10-minute duplicate runs per push. +# concurrency: +# group: code-quality-${{ github.workflow }}-${{ github.ref }} +# cancel-in-progress: true +# +# permissions: +# contents: read +# +# jobs: +# quality: +# uses: tracebloc/org-config/.github/workflows/code-quality.yml@main +# with: +# python: true # repos with Python +# shell: true # repos with shell scripts +# # soft-fail: false # flip once the backlog is clear +# secrets: +# app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} +# app-private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} +# +# This workflow asks only for `contents: read`, so it runs under a minimal +# caller and the org default workflow-token permission of "read". It needs +# EXACTLY TWO secrets, declared REQUIRED under `on.workflow_call.secrets`: +# the App id and key that mint a read-only token for tracebloc/org-config, +# the PRIVATE repo the house-rules and pipefail scripts are checked out of. +# Callers pass those two BY NAME and nothing else -- decided under +# RFC-BACKEND-1405 Q5 (backend#1420, unwound in backend#1526; the carve-out +# for these two on org-config#3). `inherit` was briefly the convention +# ("harmless no-op, nothing is referenced"), but callers pin `@main`: the day +# a reusable gains a `${{ secrets.X }}` step, every inheriting caller hands it +# that repo's ENTIRE secret set (`toJSON(secrets)` dumps it). Explicit +# per-secret passing keeps the need visible in every caller diff, and the +# REQUIRED declaration makes a caller that forgets fail at startup rather than +# at run time with an empty app-id. Cursor Bugbot's recurring nag about the +# "missing" `inherit` line is suppressed per-repo in `.cursor/BUGBOT.md` (the +# backend#1304 flywheel); the inverse -- a caller that ADDS `secrets: inherit` +# -- is what deserves a finding. +# +# SUPPLY CHAIN +# gitleaks is installed from its release tarball pinned by version AND +# verified against a pinned SHA-256, rather than via gitleaks-action. Two +# reasons: gitleaks-action requires a GITLEAKS_LICENSE for organization-owned +# repos (it would simply fail here), and a version+digest pin on the artefact +# we actually execute is a stronger guarantee than a commit pin on a wrapper +# that downloads it for us. `actions/checkout` is pinned to a commit SHA. +# +# EXTENDING house-rules +# Rules live in `scripts/house-rules.sh` in this repo. A repo adds its own +# without touching the shared workflow by committing `.house-rules.conf`: +# +# exclude: third_party/* +# timeout-wrapper: guard # a wrapper that already bounds time +# disable: curl-timeout +# rule: no-print | *.py | ^[[:space:]]*print\( | use client_logger, not print() +# +# Run it locally exactly as CI does: ./house-rules.sh --all +# (`--help` documents every rule, every exclusion, and the ignore pragma.) + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review] + # Manual whole-tree scan (gitleaks baseline etc.) -- runs every enabled + # job in all-files mode instead of a PR diff. + workflow_dispatch: + inputs: + all-files: + description: "Scan the whole repo, not a diff" + type: boolean + default: true + +concurrency: + group: code-quality-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Only `contents: read`. Requesting more would exceed a minimal caller's grant +# and fail the run at startup with no jobs. +permissions: + contents: read + +jobs: + # ---------------------------------------------------------------- Python ---- + early-close: + name: quality / pipefail early-close + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - 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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 + + - name: Check out the shared checker + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tracebloc/org-config + token: ${{ steps.org-config-token.outputs.token }} + ref: ${{ 'main' }} + path: .quality-tools + persist-credentials: false + + # PyYAML IS PROVISIONED, NEVER ASSUMED. The YAML phase imports it, and a + # missing import is (correctly) exit 2 -- "cannot tell" is a finding. But + # an rc 2 is a HARD failure regardless of soft-fail, so relying on + # whatever the runner image happens to ship would turn the first run of + # this coverage into a red gate across the fleet. Same pin and same + # invocation as selftests.yml and caller-drift.yml. + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + - name: Install PyYAML for the YAML run-block phase + # RETRIED, BECAUSE THIS SITS ON A GATE IN 18 REPOS (@saadqbal). A single + # PyPI hiccup reddened `code-quality` fleet-wide with a message about pip + # rather than about the code -- and a gate that fails for an unrelated + # reason is one people learn to re-run without reading. + # + # Three attempts with a widening pause. Deliberately NOT `continue-on- + # error`: without PyYAML the extractor exits 2 by design ("cannot tell" + # is a finding), so swallowing this would trade a loud unrelated failure + # for a quiet real one. + run: | + set -euo pipefail + for attempt in 1 2 3; do + if python -m pip install --quiet --disable-pip-version-check \ + 'pyyaml==6.0.2'; then + exit 0 + fi + echo "::warning::pip install pyyaml failed (attempt ${attempt}/3)" + # `if` rather than `[ "$attempt" -lt 3 ] && sleep ...`, for + # READABILITY, not for correctness. I first wrote this comment + # claiming the `&&` form would trip `set -e` on the last attempt and + # skip the diagnostic below. It does not: POSIX exempts every + # command of an AND-OR list except the last, so a false `[` there + # never triggers errexit. Verified both forms reach the end at rc 0 + # before saying so, since being wrong in the other direction is how + # a comment starts teaching the bug. + if [ "$attempt" -lt 3 ]; then + sleep $(( attempt * 5 )) + fi + done + echo "::error::could not install PyYAML after 3 attempts -- the YAML" + echo "::error::run-block phase cannot run, and its extractor treats a" + echo "::error::missing PyYAML as 'cannot tell' rather than as clean." + exit 1 + + - name: pipefail early-close + env: + SOFT_FAIL: ${{ false }} + YAML_RUN_BLOCKS: ${{ true }} + YAML_SOFT_FAIL: ${{ true }} + run: | + set -uo pipefail + GATE=".quality-tools/scripts/pipefail-early-close.sh" + chmod +x "$GATE" + + # TWO SCOPES, ONE RULE (backend#2967). `shell` is the tree's shell + # files -- the verdict this job has always produced, under whatever + # `soft-fail` the caller chose. `yaml` is the `run:` blocks of + # workflows and composite actions, which were never in scope at all: + # the classifier is extension-else-shebang and YAML is neither, so + # the gate reported SUCCESS on `e2e-test-agent@f4d6fec`'s live + # `printf | head -1`. Both halves are judged by the SAME awk; only + # the file list differs. + # + # THEY RUN AS SEPARATE INVOCATIONS, not one `all` pass, because the + # two halves need different blocking behaviour during the migration + # and a single exit code cannot say which half produced it. + OVERALL=0 + for scope in shell yaml; do + if [ "$scope" = yaml ]; then + [ "$YAML_RUN_BLOCKS" = "true" ] || continue + SOFT="$YAML_SOFT_FAIL" + TITLE="pipefail early-close (YAML run blocks)" + else + SOFT="$SOFT_FAIL" + TITLE="pipefail early-close" + fi + OUT="/tmp/early-close-$scope.out" + ERR="/tmp/early-close-$scope.err" + + # PIPEFAIL_ROOT is the CALLER's checkout. The `.quality-tools` tree + # sits inside the workspace but outside the caller's git index, so + # `git ls-files` cannot reach it and the checker never lints itself. + RC=0 + PIPEFAIL_SCOPE="$scope" PIPEFAIL_ROOT="$GITHUB_WORKSPACE" \ + bash "$GATE" > "$OUT" 2>"$ERR" || RC=$? + cat "$OUT" + cat "$ERR" >&2 || true + + # ONLY 0 AND 1 ARE VERDICTS. 0 = clean, 1 = findings; anything else is + # the gate failing to run -- rc 2 (cannot tell), 126/127 (not + # executable / not found), a signal death. Those are ALWAYS fatal, + # soft-fail or not: a gate that could not check has not passed, and + # letting soft-fail swallow it is how a gate becomes decoration + # (backend#1729 rule 3). + # + # Whitelisting the verdicts rather than blacklisting rc=2 is the + # load-bearing part. The first version tested `[ "$RC" = 2 ]`, so a + # missing or non-executable script exited 127, fell through to the + # soft-fail branch, and reported green (Bugbot, .github#300). + if [ "$RC" != 0 ] && [ "$RC" != 1 ]; then + echo "::error title=$TITLE::the gate exited $RC — it did not run to a verdict, so this is not a pass" + { + echo "### $TITLE" + echo "" + echo "**The gate exited \`$RC\`** — not a verdict (0 = clean, 1 = findings)." + echo "Hard failure regardless of \`soft-fail\`: a gate that could not check has not passed." + echo "" + echo '```' + head -20 "$ERR" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + exit "$RC" + fi + + if [ "$SOFT" = "true" ]; then LEVEL=warning; else LEVEL=error; fi + # Output is `path:line: code`. + awk -v lvl="$LEVEL" -v title="$TITLE" -F: ' + /^[^:]+:[0-9]+: / { + msg=$0; sub(/^[^:]*:[0-9]+: /, "", msg) + printf "::%s file=%s,line=%s,title=%s::pipes into an early-closing reader under errexit+pipefail; use a here-string or capture-then-slice: %s\n", lvl, $1, $2, title, msg + }' "$OUT" + + COUNT=$(grep -cE "^[^:]+:[0-9]+: " "$OUT" || true) + { + echo "### $TITLE" + echo "" + if [ "$COUNT" = "0" ]; then + echo "No findings." + else + echo "**$COUNT finding(s).** Each pipes into a reader that closes before EOF" + echo "(\`head\`, \`grep -q\`, \`grep -m N\`, \`sed q\`, \`read\`) where errexit + pipefail are both live." + echo "Use a here-string (\`head -25 <<<\"\$out\"\`) or capture-then-slice." + echo "If an instance is genuinely safe, mark the line \`# pipefail-guard: allow\` and say why." + echo "" + echo '```' + head -100 "$OUT" + echo '```' + fi + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$RC" != "0" ] && [ "$SOFT" = "true" ]; then + echo "$TITLE: soft-fail is on — reporting only, not failing this job." + elif [ "$RC" != "0" ]; then + OVERALL=1 + fi + done + exit "$OVERALL" + + # --------------------------------------------------------------- gitleaks --- + gitleaks: + name: quality / gitleaks + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Full history: the scan walks the PR's commit range, so the base + # commit has to exist locally. A shallow clone silently scans nothing. + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Install gitleaks (version + SHA-256 pinned) + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + run: | + set -euo pipefail + URL="https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + curl -fsSL --tlsv1.2 --connect-timeout 30 --max-time 300 "$URL" -o /tmp/gitleaks.tar.gz + echo "${GITLEAKS_SHA256} /tmp/gitleaks.tar.gz" | sha256sum -c - + tar -xzf /tmp/gitleaks.tar.gz -C /tmp gitleaks + chmod +x /tmp/gitleaks + /tmp/gitleaks version + + - name: Scan for leaked credentials + env: + ALL_FILES: ${{ (inputs.all-files || false) }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + BASELINE: ${{ '' }} + SOFT_FAIL: ${{ false }} + run: | + set -uo pipefail + + set -- --no-banner --redact --report-format json --report-path /tmp/gitleaks.json + if [ -n "${BASELINE:-}" ] && [ -f "$BASELINE" ]; then + echo "Applying baseline: $BASELINE" + set -- "$@" --baseline-path "$BASELINE" + fi + # A .gitleaks.toml at the repo root is picked up automatically. + + # EVERY invocation below is `gitleaks git` — commit-scoped — and the + # per-repo baselines DEPEND on that: baseline fingerprints embed the + # commit SHA that introduced each finding, so they only match findings + # produced by a git-mode scan. Switching any branch here to `gitleaks + # dir`/`detect` (path-scoped fingerprints) silently invalidates every + # baseline entry fleet-wide at once — 277 suppressions un-suppress in + # a single run (backend#1404 §4). Permanent fixtures live in per-repo + # `.gitleaks.toml` allowlists instead (auto-loaded from the checkout + # root, commit-INDEPENDENT); only genuine historical exposure stays + # baselined, tracked for rotation in backend#1355. + RC=0 + if [ "$ALL_FILES" = "true" ] || [ -z "${BASE_SHA:-}" ]; then + echo "Scanning the full history." + /tmp/gitleaks git "$@" . || RC=$? + else + # Exactly the commits this PR adds — every version of every line it + # introduced, so a value added and then deleted again is still found. + echo "Scanning commit range ${BASE_SHA}..${HEAD_SHA}" + /tmp/gitleaks git "$@" --log-opts="--no-merges ${BASE_SHA}..${HEAD_SHA}" . || RC=$? + # gitleaks uses 0 = clean, 1 = leaks found; any other code is an + # operational error (missing base.sha, bad log-opts). Don't let a + # broken credential scan look clean — fall back to a full-history + # scan, the same as ruff/shellcheck/house-rules (Bugbot #65). If the + # FALLBACK errors too, RC carries that code into the verdict check + # below and the job fails -- it used to flow into the soft-fail + # branch and exit 0 (backend#3658). + if [ "$RC" != "0" ] && [ "$RC" != "1" ]; then + echo "::warning::gitleaks range scan errored (exit $RC) — falling back to a full-history scan." + RC=0 + /tmp/gitleaks git "$@" . || RC=$? + fi + fi + + # ONLY 0 AND 1 ARE VERDICTS (Bugbot, org-config#3; backend#3658). Any + # other code -- from the full scan, or from the fallback after the range + # scan already failed once -- is a scanner that could not run: a broken + # binary, an unreadable repository, a signal death, 126/127. That is not + # "zero leaks", and it exits 2 HERE, before the soft-fail branch at the + # end of this step can turn it into a green job. soft-fail governs + # findings (RC = 1), nothing else. Whitelist, not `= 2` (.github#300). + if [ "$RC" != "0" ] && [ "$RC" != "1" ]; then + echo "::error::gitleaks could not run (exit $RC) — the result is UNKNOWN, not clean" + { + echo "### gitleaks" + echo "" + echo "**gitleaks exited \`$RC\`** — not a verdict (0 = clean, 1 = leaks found). Hard failure regardless of \`soft-fail\`: a scan that could not run has not passed." + echo "" + } >> "$GITHUB_STEP_SUMMARY" + exit 2 + fi + + # A report that exists but cannot be read is a scan whose result is + # UNKNOWN -- it must never count as zero findings. `|| echo 0` did exactly + # that, and under the default soft-fail it exited 0 as all-clear (Bugbot, + # org-config#3). Exit 2 here is deliberately outside the soft-fail path: + # soft-fail governs FINDINGS, not a scanner that could not be read. + COUNT=0 + if [ -s /tmp/gitleaks.json ]; then + if ! COUNT=$(jq 'length' /tmp/gitleaks.json 2>/tmp/gitleaks-jq.err); then + echo "::error::gitleaks wrote a report that jq cannot parse -- the scan result is UNKNOWN, not clean: $(tr '\n' ' ' /dev/null` already intended; it is near-unreachable anyway, since + # COUNT != 0 means jq already parsed this file once. + ROWS="" + if [ "$COUNT" != "0" ]; then + ROWS=$(jq -r '.[] | "| \(.RuleID) | `\(.File)` | \(.StartLine) | \(.Commit[0:8]) |"' \ + /tmp/gitleaks.json 2>/dev/null || true) + fi + + { + echo "### gitleaks" + echo "" + if [ "$COUNT" = "0" ]; then + echo "Nothing detected." + else + echo "**$COUNT finding(s).** Values are redacted here and in the log." + echo "" + echo "| Rule | File | Line | Commit |" + echo "|---|---|---:|---|" + # Guarded: `head <<<""` would emit one blank line and break the + # table, where the old pipeline printed nothing. + if [ -n "$ROWS" ]; then + head -50 <<<"$ROWS" + fi + echo "" + echo "Treat anything detected here as compromised: **rotate it first**, then" + echo "remove it from the code. Rewriting history is not remediation — the" + echo "value was already pushed. A deliberate false positive belongs in" + echo "\`.gitleaks.toml\` (allowlist) or in a committed baseline report." + fi + echo "" + } >> "$GITHUB_STEP_SUMMARY" + + if [ "$COUNT" != "0" ]; then + if [ "$SOFT_FAIL" = "true" ]; then LEVEL=warning; else LEVEL=error; fi + jq -r --arg lvl "$LEVEL" '.[] | "::" + $lvl + " file=" + .File + ",line=" + (.StartLine|tostring) + ",title=gitleaks: " + .RuleID + "::Detected by rule " + .RuleID + ". Rotate the value, then remove it from the code."' \ + /tmp/gitleaks.json 2>/dev/null || true + fi + + if [ "$RC" != "0" ] && [ "$SOFT_FAIL" = "true" ]; then + echo "soft-fail is on — reporting only, not failing this job." + echo "Note: this is the one job worth flipping soft-fail off for first." + exit 0 + fi + exit "$RC" + + # ------------------------------------------------------------ house rules --- + house-rules: + name: quality / house-rules + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - 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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 + + - name: Check out the shared checker + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tracebloc/org-config + token: ${{ steps.org-config-token.outputs.token }} + # Same repo this workflow lives in, so `main` is the version that + # matches a `@main` caller. `quality-ref` overrides it for testing. + ref: ${{ 'main' }} + path: .quality-tools + persist-credentials: false + + - name: Run the house-rules checker + env: + ALL_FILES: ${{ (inputs.all-files || false) }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + CONFIG: ${{ '.house-rules.conf' }} + EXCLUDE: ${{ '' }} + SOFT_FAIL: ${{ false }} + run: | + set -uo pipefail + CHECKER=".quality-tools/scripts/house-rules.sh" + chmod +x "$CHECKER" + + set -- --github --summary "$GITHUB_STEP_SUMMARY" + if [ -n "${CONFIG:-}" ] && [ -f "$CONFIG" ]; then + set -- "$@" --config "$CONFIG" + fi + if [ -n "${EXCLUDE:-}" ]; then + set -- "$@" --exclude "$EXCLUDE" + fi + # The checker's own tree must not be linted as if it were the repo's. + set -- "$@" --exclude '.quality-tools/*' + if [ "$SOFT_FAIL" = "true" ]; then + set -- "$@" --soft-fail + fi + if [ "$ALL_FILES" = "true" ] || [ -z "${BASE_SHA:-}" ]; then + set -- "$@" --all + else + set -- "$@" --base "$BASE_SHA" + fi + + "$CHECKER" "$@" + + # RFC-0087 D3 (backend#3523): what a repo declares, ships and installs must + # be reachable from what it runs. Rides THIS job on purpose -- `quality / + # house-rules` is a required status check on every train repo's develop + # (measured 2026-09-09), so the checker is a gate the day it lands without + # 16 branch-protection edits; a new job name would be advice (rule 2) + # until each protection was hand-updated. + # + # WHOLE-TREE, never diff-scoped: removing an import elsewhere is what makes + # a pin dead, and that hunk is never in the PR that added the pin. + # + # Advisory by default (`dead-weight-soft-fail: true`) and hard per caller, + # so the fleet does not go red on promotion day. Python >= 3.11 for + # tomllib; ubuntu-latest ships 3.12. The version guard is a real refusal, + # not a skip: a checker that silently ran nothing would report clean. + - name: Run the dead-weight checker + env: + CONFIG: ${{ '.house-rules.conf' }} + EXCLUDE: ${{ '' }} + SOFT_FAIL: ${{ true }} + run: | + set -uo pipefail + CHECKER=".quality-tools/scripts/dead-weight.py" + python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 11) else 1)' || { + echo "::error::dead-weight needs python3 >= 3.11 (tomllib); the runner has $(python3 --version)" + exit 1 + } + + set -- --root . --github --summary "$GITHUB_STEP_SUMMARY" + if [ -n "${CONFIG:-}" ]; then + set -- "$@" --config "$CONFIG" + fi + if [ -n "${EXCLUDE:-}" ]; then + set -- "$@" --exclude "$EXCLUDE" + fi + set -- "$@" --exclude '.quality-tools/*' + if [ "$SOFT_FAIL" = "true" ]; then + set -- "$@" --soft-fail + fi + + python3 "$CHECKER" "$@" + + # Whole-tree action-pin gate (backend#1492, D10). Hand-pinning does not hold: + # while one PR pinned jlumbroso/free-disk-space, a second PR added a NEW + # unpinned call site of the same action in a non-overlapping hunk of the same + # file — both merged cleanly, no conflict, caught only by a human reading the + # diff (#1446/#1449). This job makes that silent recurrence impossible. + # + # WHOLE-TREE, NEVER DIFF-SCOPED — deliberately. A guard that inspects only + # what a PR adds reports clean forever over the existing backlog (the exact + # failure mode of PR-mode gitleaks on frontend-app). Every run scans every + # workflow file in the checkout. + # + # THE GRAMMAR, and why not a YAML parse: pin-checking needs the `uses:` REF + # STRINGS, not the workflow's semantic structure. A strict line grammar + # (comment lines excluded; quoted refs unwrapped) covers every real workflow + # in the fleet, and anything it cannot parse — e.g. `uses: ${{ ... }}` + # expressions — is REPORTED as a finding rather than skipped: a guard that + # cannot verify must refuse to claim it did (RFC-1405 property 2; with + # soft-fail off, that refusal is red). + # + # ALLOWED forms (everything else is a finding): + # ./local/path repo-local actions + # tracebloc/<...>@main org reusables float on @main BY DECISION + # (RFC-1405 Q3; any other tracebloc ref is + # drift, same rule as caller-drift's) + # owner/action[/path]@<40-hex sha> D10 pin (trailing "# vX.Y.Z" comment is + # convention but not enforced here) + # docker://image@sha256: digest-pinned images only + action-pins: + name: quality / action-pins + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + + - name: Scan every workflow for unpinned action refs + env: + # The pin-specific override alone decides this job's posture, so a repo + # can arm the supply-chain check while the rest of its suite is still + # advisory during a migration. That is the whole reason this input is + # separate from `soft-fail`. + # + # It used to read `(inputs.soft-fail || inputs.action-pins-soft-fail)`. + # Because `soft-fail` defaults to true, that OR made the override + # incapable of EVER arming the job -- it could only ever weaken it -- + # while 16 of 16 callers pass `action-pins-soft-fail: false` expecting + # the opposite, and four of them carry a comment claiming the check is + # "Armed ... independent of soft-fail above". It was not (backend#1681). + SOFT_FAIL: ${{ false && 'true' || 'false' }} + run: | + set -euo pipefail + python3 - <<'PY' + import glob, os, re, sys + + USES = re.compile(r"^\s*-?\s*uses:\s*(.+?)\s*$") + SHA_PIN = re.compile(r"^[\w.-]+/[\w.-]+(/[\w./-]+)?@[0-9a-f]{40}$") + TB_MAIN = re.compile(r"^tracebloc/[\w.-]+(/[\w./-]+)*@main$") + DOCKER_DIGEST = re.compile(r"^docker://\S+@sha256:[0-9a-f]{64}$") + + # Built from parts so this SOURCE never contains a literal GitHub + # expression opener - actionlint validates expressions inside run: + # blocks and rejects the bare sequence even inside a Python string. + EXPR_MARKER = "$" + "{{" + + findings = [] + files = sorted(glob.glob(".github/workflows/*.yml") + glob.glob(".github/workflows/*.yaml")) + for f in files: + for lineno, raw in enumerate(open(f, encoding="utf-8"), 1): + stripped = raw.lstrip() + if stripped.startswith("#"): + continue + # drop a trailing comment BEFORE matching, so "@sha # v4" parses + code = re.split(r"\s#", raw, 1)[0] + m = USES.match(code) + if not m: + continue + ref = m.group(1).strip().strip("'\"") + if ref.startswith("./"): + continue + # tracebloc/* is judged FIRST: the org convention is @main and + # nothing else, so a tracebloc ref frozen on a SHA is drift, + # not a pin (Bugbot, .github#159) - SHA_PIN must never see it. + if ref.startswith("tracebloc/"): + if TB_MAIN.match(ref): + continue + why = "tracebloc/* must be @main (Q3) - any other ref is drift" + elif SHA_PIN.match(ref) or DOCKER_DIGEST.match(ref): + continue + elif EXPR_MARKER in ref: + why = "expression ref - cannot be verified, so it is refused (property 2)" + else: + why = "not pinned to a 40-char commit SHA (D10)" + findings.append((f, lineno, ref, why)) + + if not files: + # Any repo CALLING this reusable necessarily has at least one + # workflow file (its own caller), so an empty glob means the + # checkout or working directory is wrong - a malfunction, not a + # clean tree. Malfunctions fail even under soft-fail: that flag + # governs FINDINGS, never the scan's own integrity (Bugbot, + # .github#159; the watchdog's findings-vs-malfunction split). + print("::error::action-pins scanned ZERO workflow files - the checkout or cwd is wrong; refusing to report a pass (backend#1492)") + sys.exit(2) + + level = "warning" if os.environ.get("SOFT_FAIL") == "true" else "error" + for f, lineno, ref, why in findings: + print(f"::{level} file={f},line={lineno}::unpinned action ref '{ref}' - {why} (backend#1492)") + + # The summary carries the FULL list, like ruff/format/gitleaks do: + # annotations cap at ten per step, so a backlog-carrying repo would + # otherwise show ten warnings and a bare number (Bugbot, .github#159). + summary = os.environ.get("GITHUB_STEP_SUMMARY") + if summary: + with open(summary, "a", encoding="utf-8") as out: + out.write(f"## action-pins\n\nscanned {len(files)} workflow file(s), {len(findings)} finding(s)\n\n") + if findings: + out.write("| file | line | ref | why |\n|---|---|---|---|\n") + for f, lineno, ref, why in findings: + out.write(f"| {f} | {lineno} | `{ref}` | {why} |\n") + + print(f"action-pins: {len(files)} file(s) scanned, {len(findings)} finding(s)") + if findings and os.environ.get("SOFT_FAIL") != "true": + sys.exit(1) + if findings: + print("soft-fail is on - reporting only, not failing this job.") + PY From b56e810fa30876364f4f9edc7cec7bbf210fc58f Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:05 +0200 Subject: [PATCH 04/12] chore(onboard): add .github/workflows/customer-priority-bump.yml (content profile) --- .github/workflows/customer-priority-bump.yml | 567 +++++++++++++++++++ 1 file changed, 567 insertions(+) create mode 100644 .github/workflows/customer-priority-bump.yml diff --git a/.github/workflows/customer-priority-bump.yml b/.github/workflows/customer-priority-bump.yml new file mode 100644 index 0000000..978fb33 --- /dev/null +++ b/.github/workflows/customer-priority-bump.yml @@ -0,0 +1,567 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/customer-priority-bump.yml` +# (blob f3c865da70e5d4b9eda8c49a229ba2ca829d06cd) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/customer-priority-bump-caller.yml` (blob ac9868ebd437e0c4ee636ca9ff1a68b5a2f72fa0), 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 tracebloc/org-config. 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 +# `bump / ` prefix so the check contexts branch protection requires do +# not change. Scripts are still fetched from org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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: Label-driven issue triage + +# Reusable workflow, called by a thin `customer-priority-bump.yml` caller in +# each wired repo on issues.types=labeled (callers pass `secrets: inherit` and +# no inputs). TWO label rules live here, both keyed on the label that was just +# added: +# +# from:customer -> add the binary `priority` label to the issue (D5). +# Writes no board field -- the Priority single-select was +# removed from the board under D5. +# work-type:bug -> move the issue's kanban card from `Backlog` to `Ready` +# (backend#2348). Defects skip refinement. +# +# WHY BOTH LIVE IN ONE FILE, AND WHY THE FILENAME NO LONGER MATCHES +# ---------------------------------------------------------------- +# The bug rule needs exactly one thing: to run on `issues: labeled` in every +# repo. This reusable is the ONLY place in the org that already does, and 16 of +# the 19 repos already call it. Adding a second reusable instead would need a +# `repo-inventory.yml` row for all 19 repos plus a caller rollout -- and +# `repo-inventory.yml` is guarded by `conformance-gate.yml`, so that change +# cannot merge until an org audit passes on its exact head sha, which every other +# merge invalidates. A rule that has been unimplemented since it was written does +# not need to wait behind that; it needs to run. +# +# The cost is honest and stated: the file name and the 16 per-repo caller names +# still say "customer priority bump", so a bug-label run shows up in each repo's +# Actions tab under that name. The workflow's own `name:` above is the half that +# could be fixed without touching 16 repos, so it was. Renaming the file is a +# caller rollout (BUGBOT.md property 1: land the callee first) and is still open +# work -- it no longer waits on anything, since the wiring below is complete. +# +# WIRED EVERYWHERE NOW. Every repo has a caller (`.github`'s landed under +# backend#2396) and `customer_priority_bump_caller_missing` is GONE from +# `repo-inventory.yml` -- no repo carries that key any more, so any text sending a +# reader to that lookup is describing a key that cannot answer. (The STRING still +# appears elsewhere in the repo, in `scripts/reason-citations.py` and in this +# comment; it is the INVENTORY that no longer records it. @aptracebloc on #350.) +# +# THE LABEL GAP IS CLOSED, AND NOW ASSERTED (backend#2598). This block used to +# record a live defect: repos carrying this caller with no `work-type:*` labels at +# all. GitHub silently DROPS a template label the target repo lacks -- no error, no +# annotation, no run -- so the issue was filed unlabelled, `bug-to-ready` never saw +# its label, and the card sat in `Backlog`, the refinement queue nobody pulls from. +# The exact miss backend#2348 was filed to close, surviving in the repos a +# caller-presence check calls done. +# +# Measured 2026-08-27 before the fix: `design-system-v2`, `release-train` and `rfcs` +# had ZERO of the seven triage labels, and `e2e-test-agent` had TWO of seven. All +# twenty now have all seven, and `.github/workflows/triage-labels.yml` runs daily to +# keep it that way -- it derives the label domain from the two producers (this file's +# `*-label` input defaults plus its `--add-label` writes, and every +# `.github/ISSUE_TEMPLATE/*.yml` `labels:` entry) and asserts each label exists in +# every repo the inventory declares. So a label deleted in a repo's Settings UI is a +# red run rather than a silent stop. +# +# TWO LESSONS WORTH MORE THAN THE FIX, both about the DOMAIN and not the answer: +# +# * `e2e-test-agent` was invisible to backend#2598 as filed, because that ticket +# derived over the `work-type:*` PREFIX. That repo had `work-type:bug` and +# `priority` and none of the other five, so a prefix sweep called it covered +# while four labels its templates apply were being dropped. Deriving from a +# prefix rather than from the producers is CLAUDE.md rule 6's vocabulary gap -- +# committed by the ticket that was written to close it. +# * An earlier version of this comment named TWO repos, because it checked the +# repos the previous prose happened to name instead of the domain the inventory +# declares (@aptracebloc, #350). +# +# Both are why the check parses the producers and the inventory and holds no list of +# its own. Do not reintroduce one here. + +on: + issues: + types: [labeled] + +jobs: + bump: + name: bump / bump + if: github.event.label.name == 'from:customer' + runs-on: ubuntu-latest + steps: + # Authenticates as the tracebloc-release-train App (backend#2036) instead of + # one human's PAT. This one needs `issues: write` -- it labels an issue in the + # calling repo -- which the App gained alongside `organization_projects: write`. + # `owner:` makes the token ORG-scoped so the same mint works for every caller. + # No fallback to the old PAT: a fallback would let a broken App path look like + # a working migration. + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 ONE CALL (backend#2157). The + # step below runs `gh issue edit --add-label priority` and nothing else: + # it resolves the issue and the repo's label set (Issues read) and writes + # the label (Issues write). `issues: write` subsumes both. + # + # Nothing here touches a project board, a pull request, or repository + # content -- so `organization-projects`, `pull-requests` and + # `contents:write` all drop, along with the administration/actions/checks + # reads the unscoped mint was carrying. + # + # `repositories:` is deliberately NOT narrowed, matching the reasoning on + # the `bug-to-ready` mint below: repo narrowing is measured in this org + # for a READ (add-to-kanban.yml, backend#2181) and not for a WRITE, and an + # unmeasured narrowing on a workflow that fires from every repo in the + # fleet fails red on every customer-labelled issue. + # + # NOT PROVEN BY READING: an under-scoped token fails at the call, not at + # the mint. The first `from:customer` label after this lands is the test. + permission-issues: write + + - name: Label the customer issue as priority + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + REPO_FULL: ${{ github.repository }} + run: | + set -euo pipefail + # D5: priority is a binary "priority" label, not a project field. The + # label is visible on the issue itself and filterable everywhere; the + # Priority single-select field has been removed from the board. + # + # THE TRIGGER LABEL'S OWN DESCRIPTION NOW SAYS SO TOO, and for months it + # said the opposite (backend#2743). All twenty enrolled repos advertised + # `from:customer` as "auto-bumps Priority to P1" -- an outcome D5 deleted + # and this step stopped producing -- so the label that fires the rule + # promised a board value nobody would ever see. It was uniformly wrong + # because .github#364 created the labels four repos lacked by faithfully + # copying names, colours and descriptions off the repos that had them. + # Swept across all twenty on 2026-08-27, byte-identical, to: + # + # Filed from a customer request — automation adds the binary `priority` label + # + # Derived, not chosen: the sweep refused any text that did not name the + # label the `gh issue edit` line below actually writes, and refused the + # removed vocabulary outright. Change one, change the other. + # + # NOT UNDER A CHECK YET, said plainly rather than left to be assumed: + # `triage-labels-check.py` (.github#364) asserts a triage label EXISTS in + # every enrolled repo, not that its description still describes this step + # -- which is exactly how the stale text survived D5 with a green fleet. + # backend#2744 closes that, on top of #364 rather than beside it, because + # the file it extends is not on `develop` yet. + gh issue edit "$ISSUE_NUMBER" --repo "$REPO_FULL" --add-label priority + echo "-> Issue #$ISSUE_NUMBER labelled 'priority'" + + # --------------------------------------------------------------------------- + # A bug-labelled issue lands in `Ready`, not `Backlog` (backend#2348) + # --------------------------------------------------------------------------- + # THE RULE, quoted from `org-standards.md`: "label them `work-type:bug` (the + # Bug template does it) and the board moves the card straight into `Ready`". + # + # Nothing implemented it. `add-to-kanban.yml` adds every new issue at + # `Backlog` and no workflow read the label afterwards, so the rule was carried + # by whoever remembered. Measured 2026-08-22: seven bug-labelled tickets filed + # in one day (backend#2324, #2327, #2329, #2340, #2341, #2344, + # frontend-app#871) all landed in `Backlog` and all seven needed a hand-run + # mutation. A 100% miss rate is the tell that nothing does it at all -- a rule + # people mostly follow produces a mixed record. + # + # `Ready` is the queue engineers pull from; `Backlog` is the refinement queue + # nobody pulls from. That split is the whole point, so a defect filed correctly + # and labelled correctly was invisible to the people meant to pick it up. + bug-to-ready: + name: bump / bug-to-ready + # A COST GATE, not the decision. It exists so that an unrelated label -- and + # every repo in the fleet fires this workflow on every `labeled` event -- does + # not mint an App token. The decision is `label_gate` in the step below, where + # it can be extracted, run and mutated by + # `scripts/tests/bug-to-ready-selftest.py`. `==` here is exact string + # equality, so this can only ever be STRICTER than that gate: it may skip work + # the gate would decline, never admit work the gate refuses. + if: github.event_name == 'issues' && github.event.label.name == 'work-type:bug' + runs-on: ubuntu-latest + timeout-minutes: 10 + # Nothing here uses the caller's GITHUB_TOKEN: every call is made with the + # App installation token minted below. + permissions: {} + steps: + # LEAST PRIVILEGE, DERIVED FROM WHAT THIS RUNS (backend#2157), unlike the + # `bump` job above which still carries the App's full installation grant + # (`mint-scope.py`'s EXEMPT row for this file is about that job, not this + # one). This step reads one issue's project items and writes one + # single-select field on an org project -- so `issues: read` plus + # `organization-projects: write` is the whole requirement. + # + # `repositories:` is deliberately NOT narrowed here. The board write needs + # the ORG-level grant that `owner:` yields, and the interaction between repo + # narrowing and an org ProjectV2 write is measured for a READ + # (kanban-columns.yml, backend#2181) and not for a write. An unmeasured + # narrowing on a workflow that fires for every bug in the fleet fails red on + # every defect filed, and this job's whole point is that a defect should not + # need a human to notice it. + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} + private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + permission-issues: read + permission-organization-projects: write + + - name: Promote the card to Ready, but only from Backlog + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ORG: ${{ 'tracebloc' }} + PROJECT_NUMBER: ${{ 2 }} + BUG_LABEL: ${{ 'work-type:bug' }} + # THE TWO ANCHORS, QUOTED SO THE BOARD CHECK CAN SEE THEM. + # `scripts/kanban-columns-check.py` collects every board column name a + # WRITERS workflow quotes on a code line and asserts it exists on the + # live board -- which is what stops a rename in the Projects UI turning + # this job into a silent no-op. Unquoted YAML values are invisible to it. + SOURCE_COLUMN: "Backlog" + TARGET_COLUMN: "Ready" + EVENT_NAME: ${{ github.event_name }} + LABEL_ADDED: ${{ github.event.label.name }} + # A PR is not an issue. `issues` events never fire for pull requests, so + # this is belt and braces for a caller wired to `pull_request: labeled` + # -- both payload shapes are checked because they carry the PR in + # different places. + HAS_PR_PAYLOAD: ${{ github.event.pull_request != null || github.event.issue.pull_request != null }} + NUMBER: ${{ github.event.issue.number }} + REPO_FULL: ${{ github.repository }} + run: | + set -euo pipefail + REPO_NAME="${REPO_FULL#*/}" + + # ---- 1. is this an event this job may act on? -------------------- + # selftest:label-gate-start + # THE AUTHORITATIVE EVENT/LABEL GATE. The job-level `if:` above is a + # cost gate; this is the decision, written where a test can run it. + # + # EVERY REFUSAL NAMES ITSELF. A gate with five refusal paths and one + # bare failure cannot tell a test which path it took, so a case goes on + # passing while exercising a different refusal than its name claims + # (CLAUDE.md rule 10). + label_gate() { # $1=event $2=label added $3=label we act on $4=PR payload? + if [ "${1:-}" != "issues" ]; then echo "refuse:not-an-issues-event"; return; fi + if [ "${4:-}" = "true" ]; then echo "refuse:pull-request-payload"; return; fi + if [ -z "${2:-}" ]; then echo "refuse:unreadable-label"; return; fi + if [ -z "${3:-}" ]; then echo "refuse:no-configured-label"; return; fi + if [ "$2" != "$3" ]; then echo "refuse:other-label"; return; fi + echo proceed + } + GATE=$(label_gate "${EVENT_NAME:-}" "${LABEL_ADDED:-}" "${BUG_LABEL:-}" "${HAS_PR_PAYLOAD:-}") + case "$GATE" in + proceed) + echo "'${LABEL_ADDED}' added to issue #${NUMBER} - evaluating its card" ;; + refuse:other-label) + # THE ONLY GREEN REFUSAL, and the only one that is a normal event: + # this workflow runs on every `labeled` event in every wired repo, + # so most runs land here. + echo "::notice::'${LABEL_ADDED}' is not '${BUG_LABEL}' - nothing to do" + exit 0 ;; + *) + # Every other refusal is a payload this decision was never written + # for. Unreachable through the `if:` above, which is exactly why it + # is LOUD: if it ever fires, a caller has been wired to an event + # this job cannot judge, and a quiet exit 0 would hide that for as + # long as nobody reads the run log. + echo "::error::${GATE}: this job promotes a bug-labelled ISSUE and cannot judge this payload" >&2 + exit 1 ;; + esac + # selftest:label-gate-end + + # Every GraphQL call in this job -- both reads AND the write -- goes through + # here. `gh api graphql` exits 0 on an HTTP 200 that carries a GraphQL + # `errors[]` payload, so an exit code alone cannot tell a completed + # operation from a refused one. ONE function rather than a check per call + # site: the two reads each carried their own inline copy and the write + # carried none, which is exactly how the write came to be fail-open + # (Bugbot, .github#313). Rule 1 (derive, never restate) and rule 9 -- the + # selftest extracts THIS function by name, so the assertion and the + # mutation drive the same code the job runs. + reject_graphql_errors() { # $1=raw payload $2=the message to fail with + # UNPARSEABLE IS ITS OWN ARM, and it has to come first. `jq -e + # 'has("errors")'` exits non-zero BOTH when the key is absent and when + # the input is not JSON at all, so a single check would read a truncated + # or empty body as "no errors" -- fail-open on precisely the input that + # means "cannot tell". + if ! jq -e . >/dev/null 2>&1 <<< "$1"; then + echo "::error::$2 (the response was not readable JSON, so it cannot be" \ + "shown to be error-free)" >&2 + return 1 + fi + if jq -e 'has("errors")' <<< "$1" >/dev/null 2>&1; then + echo "::error::$2" >&2 + return 1 + fi + return 0 + } + + + # ---- 2. the board, read ONCE ------------------------------------ + # One query for the project id, the Status field id, the target option + # id AND the option ORDER. The order is what the monotonic gate is + # derived from, so reading it in the same response as the ids means the + # decision and the write cannot be made against two different boards. + # + # FAIL CLOSED on the read (backend#1729 rule 3): an unreadable board is + # not evidence that the card may move. + # shellcheck disable=SC2016 # the $names are GraphQL variables, not shell - keep literal + if ! PROJ=$(gh api graphql -f query=' + query($org: String!, $num: Int!) { + organization(login: $org) { + projectV2(number: $num) { + id + fields(first: 50) { + totalCount + nodes { + ... on ProjectV2SingleSelectField { id name options { id name } } + } + } + } + } + }' -F org="$ORG" -F num="$PROJECT_NUMBER"); then + echo "::error::could not read project #${PROJECT_NUMBER} - refusing to guess where this card sits" >&2 + exit 1 + fi + # A GraphQL `errors[]` payload AT EXIT 0 is a partial read, and a partial + # read of the option list is exactly the input that makes a position + # comparison meaningless (the shape `bugbot-gate.py` pins a mutation for). + reject_graphql_errors "$PROJ" \ + "the project read came back with GraphQL errors - a partial board is not a board" || exit 1 + + FIELD_TOTAL=$(jq -r '.data.organization.projectV2.fields.totalCount // -1' <<< "$PROJ") + # A field list longer than the page read means `Status` may be on a page + # nobody looked at, and "absent from the page I read" is not "absent". + if [ "$FIELD_TOTAL" -lt 0 ] || [ "$FIELD_TOTAL" -gt 50 ]; then + echo "::error::project #${PROJECT_NUMBER} reported ${FIELD_TOTAL} fields against a page of 50 -" \ + "the Status field may be unread. Paginate rather than treating a truncated read as complete." >&2 + exit 1 + fi + + PROJECT_ID=$(jq -r '.data.organization.projectV2.id // ""' <<< "$PROJ") + STATUS_FIELD=$(jq -r '.data.organization.projectV2.fields.nodes[]? + | select(.name=="Status") | .id' <<< "$PROJ") + # THE OPTION ID IS DERIVED, NEVER HELD. A stored `Ready` option id would + # keep writing after the board changed under it, and writing the WRONG + # column is strictly worse than writing nothing (backend#2348). + TARGET_OPT=$(jq -r --arg s "$TARGET_COLUMN" '.data.organization.projectV2.fields.nodes[]? + | select(.name=="Status") | .options[] | select(.name==$s) | .id' <<< "$PROJ") + if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ] \ + || [ -z "$STATUS_FIELD" ] || [ "$STATUS_FIELD" = "null" ] \ + || [ -z "$TARGET_OPT" ] || [ "$TARGET_OPT" = "null" ]; then + echo "::error::could not resolve the Status field or its '${TARGET_COLUMN}' option in" \ + "project #${PROJECT_NUMBER}. NOTHING WAS WRITTEN - this runs before any mutation." >&2 + exit 1 + fi + + # ---- 3. where is the card now? ---------------------------------- + # THE ISSUE'S OWN projectItems, not a scan of the project. Project #2 + # carries ~700 items, so a project-side scan is a pagination bug waiting + # to happen; the issue knows which cards it has. `totalCount` is read so + # a card beyond the page cannot be reported as "not on the board". + # + # The retry is for the `opened`+template case: the Bug template applies + # the label at creation, so this can race `add-to-kanban.yml`. + ITEM_ID=""; CURRENT_COL=""; ARCHIVED="" + for attempt in 1 2 3 4 5; do + # shellcheck disable=SC2016 # the $names are GraphQL variables, not shell - keep literal + if ! IRESP=$(gh api graphql -f query=' + query($org: String!, $repo: String!, $num: Int!) { + repository(owner: $org, name: $repo) { + issue(number: $num) { + projectItems(first: 20) { + totalCount + nodes { + id isArchived project { number } + status: fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + }' -F org="$ORG" -F repo="$REPO_NAME" -F num="$NUMBER"); then + echo "::error::could not read issue #${NUMBER}'s project items - an unreadable card is not" \ + "a card that may be left where it is" >&2 + exit 1 + fi + reject_graphql_errors "$IRESP" \ + "the project-items read came back with GraphQL errors - unreadable, not 'no card'" || exit 1 + PI_TOTAL=$(jq -r '.data.repository.issue.projectItems.totalCount // -1' <<< "$IRESP") + # A MISSING connection is not an EMPTY one. `totalCount` is absent when + # the issue itself did not resolve, and retrying that four more times + # then reporting "not on the board" would name the wrong cause. + if [ "${PI_TOTAL:--1}" -lt 0 ]; then + echo "::error::the read did not describe issue #${NUMBER}'s project items at all" \ + "(no totalCount) - an unreadable response is not an empty one" >&2 + exit 1 + fi + NODE=$(jq -c --arg n "$PROJECT_NUMBER" 'first(.data.repository.issue.projectItems.nodes[]? + | select(.project.number == ($n | tonumber))) // {}' <<< "$IRESP") + ITEM_ID=$(jq -r '.id // ""' <<< "$NODE") + if [ -n "$ITEM_ID" ] && [ "$ITEM_ID" != "null" ]; then + CURRENT_COL=$(jq -r '.status.name // ""' <<< "$NODE") + ARCHIVED=$(jq -r 'if .isArchived == true then "true" else "false" end' <<< "$NODE") + break + fi + if [ "${PI_TOTAL:-0}" -gt 20 ]; then + echo "::error::issue #${NUMBER} is on ${PI_TOTAL} projects and the kanban card is not among" \ + "the 20 read - paginate rather than reporting a truncated read as 'not on the board'" >&2 + exit 1 + fi + echo "not on project #${PROJECT_NUMBER} yet (attempt ${attempt}/5) - add-to-kanban may still be running" + if [ "$attempt" -lt 5 ]; then sleep 5; fi + done + + # FAIL CLOSED, LOUDLY, AND THE DIRECTION IS THE DECISION HERE. + # + # The three sibling consumers of the board chose their failure paths as a + # SET (backend#2243), and this one resembles `advance-deploy-env.yml`: no + # fallback, abort. `kanban-closure-router.yml` exits 0 on a card it cannot + # find because declining to write is its conservative end state -- it is + # protecting shipped state from being overwritten. Declining here is not + # conservative: it reproduces the exact defect this job exists to fix, a + # defect parked in `Backlog` that nobody reads. And `kanban-reconcile.yml` + # may skip because it runs weekly and gets another go; a `labeled` event + # fires ONCE, so a green no-op is the last anyone hears of it. + if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then + echo "::error::issue #${NUMBER} carries '${BUG_LABEL}' but is not on project" \ + "#${PROJECT_NUMBER} after 5 tries. It needs '${TARGET_COLUMN}' and this run could not" \ + "put it there - check this repo's add-to-kanban caller, then set the column by hand." >&2 + exit 1 + fi + + # ---- 4. may the card move? -------------------------------------- + # Byte-identical to `kanban-closure-router.yml`'s, and asserted so by + # the selftest: both read the same `$PROJ` shape, so a divergence would + # be a defect rather than a difference. + col_index() { + echo "$PROJ" | jq -r --arg s "$1" \ + '[.data.organization.projectV2.fields.nodes[] + | select(.name=="Status") | .options[].name] | index($s) // -1' + } + # selftest:monotonic-start + # MONOTONIC. Automation in this org never moves a card backward, and the + # direction is asked of the BOARD rather than restated here: a rank table + # in this file would agree with itself while disagreeing with reality + # (backend#1729 rule 1). + # + # promote the card is at $SOURCE_COLUMN, or is on the board with no + # Status at all. Those are the only two states from which + # $TARGET_COLUMN is forward. An unplaced card is not "past + # Ready" -- leaving it unplaced keeps it invisible, which is + # the complaint. + # hold anywhere else: `In progress`, `Code review`, `On dev`, + # `FR on staging`, `Ready for prod`, `Prod`, `Done`, + # `Cancelled`, `North Stars`, already `Ready`, or archived. + # The label routinely arrives AFTER triage moved the card, and + # demoting a shipped card would un-ship it on the board. + # unknown a column the board does not report. Nothing can be said + # about "forward" from a position that cannot be placed. + # noboard an anchor is missing, or $TARGET_COLUMN does not sit + # strictly AFTER $SOURCE_COLUMN. The second half is the + # monotonicity assertion itself: position is load-bearing, so + # one drag of the Status options in the UI can make this + # "promotion" a demotion. Checked FIRST, before the + # no-Status shortcut, because a board that cannot be trusted + # to be in pipeline order cannot be trusted for any card + # (backend#1994 is the same hole one file over: existence was + # checked and ORDER was not). + promote_decision() { # $1=the card's current column $2=isArchived + _s=$(col_index "${SOURCE_COLUMN}"); _t=$(col_index "${TARGET_COLUMN}") + if [ "$_s" -lt 0 ] || [ "$_t" -lt 0 ] || [ "$_s" -ge "$_t" ]; then echo noboard; return; fi + if [ "${2:-}" = "true" ]; then echo hold; return; fi + case "${1:-}" in + ""|"No status") echo promote; return ;; + esac + _c=$(col_index "$1") + if [ "$_c" -lt 0 ]; then echo unknown; return; fi + if [ "$_c" -eq "$_s" ]; then echo promote; else echo hold; fi + } + # selftest:monotonic-end + + # selftest:policy-start + _d=$(promote_decision "${CURRENT_COL:-}" "${ARCHIVED:-}") + case "$_d" in + promote) + _write=yes ;; + hold) + _write=no + echo "::notice::#${NUMBER} sits in '${CURRENT_COL:-}' (archived=${ARCHIVED:-false})," \ + "not '${SOURCE_COLUMN}' - leaving it alone, automation never moves a card backward" ;; + unknown) + # NOT a quiet decline. The router's `unknown` arm exits 0 because + # there, declining is the safe end state; here it means a card that + # should be in the pull queue is somewhere this job cannot place, + # and nobody would ever hear about it. + echo "::error::#${NUMBER} sits in '${CURRENT_COL:-}', which project #${PROJECT_NUMBER}" \ + "does not report as a Status option - refusing to guess whether '${TARGET_COLUMN}' is forward" >&2 + exit 1 ;; + noboard) + echo "::error::project #${PROJECT_NUMBER} does not place '${SOURCE_COLUMN}' strictly before" \ + "'${TARGET_COLUMN}' in its Status options. Promoting would be a DEMOTION, so nothing was" \ + "written. Check the column order and names on the board." >&2 + exit 1 ;; + *) + # No fall-through. An unrecognised verdict is a code defect, and the + # one thing it must not do is reach the write. + echo "::error::unrecognised promotion verdict '${_d}' - refusing to write" >&2 + exit 1 ;; + esac + # selftest:policy-end + + if [ "$_write" != "yes" ]; then + exit 0 + fi + + # shellcheck disable=SC2016 # the $names are GraphQL variables, not shell - keep literal + if ! WRESP=$(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="$STATUS_FIELD" -f o="$TARGET_OPT"); then + echo "::error::the Status write failed for issue #${NUMBER} - the card is still in" \ + "'${CURRENT_COL:-}' and needs '${TARGET_COLUMN}' by hand" >&2 + exit 1 + fi + # The write goes through the SAME rejection as the two reads. This event + # fires exactly ONCE, so a false success here is permanent: the step logs a + # Backlog -> Ready move, stays green, and the card never moved. + reject_graphql_errors "$WRESP" \ + "the Status write for issue #${NUMBER} came back with GraphQL errors at exit 0 - the card is still in '${CURRENT_COL:-}' and needs '${TARGET_COLUMN}' by hand" || exit 1 + # Absence of errors is not presence of the write. Confirm the mutation + # returned the item it claims to have moved -- the same "did it actually + # land" discipline merge-confirm.sh applies to a merge. + if [ -z "$(jq -r '.data.updateProjectV2ItemFieldValue.projectV2Item.id // empty' <<< "$WRESP")" ]; then + echo "::error::the Status write for issue #${NUMBER} returned no item id, so the move is UNCONFIRMED - the card needs '${TARGET_COLUMN}' by hand" >&2 + exit 1 + fi + echo "-> issue #${NUMBER}: Status '${CURRENT_COL:-}' -> '${TARGET_COLUMN}'" + { + echo "### Defect skipped refinement" + echo + echo "\`${BUG_LABEL}\` on issue #${NUMBER}: \`${CURRENT_COL:-}\` -> \`${TARGET_COLUMN}\`" + } >> "$GITHUB_STEP_SUMMARY" From d7e872df0b8788cee2c9e70dc890e547ffd8fc93 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:07 +0200 Subject: [PATCH 05/12] chore(onboard): add .github/workflows/fr-gate.yml (content profile) --- .github/workflows/fr-gate.yml | 238 ++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 .github/workflows/fr-gate.yml diff --git a/.github/workflows/fr-gate.yml b/.github/workflows/fr-gate.yml new file mode 100644 index 0000000..422efb4 --- /dev/null +++ b/.github/workflows/fr-gate.yml @@ -0,0 +1,238 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/fr-gate.yml` +# (blob 948ea255acb8ea4e4d7a259fe16fec2855ddac32) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/fr-gate-caller.yml` (blob b2e63da19a0d0369a7d7364b4596f53128f3c8ca), 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 tracebloc/org-config. 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 +# `gate / ` prefix so the check contexts branch protection requires do +# not change. Scripts are still fetched from org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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: FR gate + +# Reusable workflow. Called from each active repo on pull_request events +# targeting staging, main, or master. Blocks the merge unless every item +# included in the promotion is at or beyond the correct "Ready for X" column: +# +# target = staging → all items must be at "On dev" or later (automatic — D6) +# target = main/master → all items must be at "Ready for prod" or later +# +# "or later" means an item already further down the pipeline (e.g. "Prod") +# satisfies an earlier gate ("On dev") instead of being falsely +# blocked. See the rank() helper below for the canonical stage ordering. +# +# This enforces the "FR must pass before promotion" rule. It runs as a +# required status check (configured via branch protection) so the merge +# button stays grey until the gate passes. +# +# Override: add the "skip-fr-gate" label to bypass the check (for hotfixes +# or emergency releases). The label is a deliberate, visible action so we +# can audit overrides after the fact. +# +# Item discovery: authoritative commit->PR attribution via the +# commits/{sha}/pulls API between base and head (see the discovery step). +# Release-train promotion PRs (head release-train/*) are transparent +# plumbing: they never appear as gated items and never vouch for their +# range -- attribution passes through to the cargo PRs they carry. If +# discovery yields no PRs at all, falls back to checking the promotion +# PR's own Status. + +on: + pull_request: + types: [opened, reopened, synchronize, ready_for_review, labeled, unlabeled, edited] + +jobs: + gate: + name: gate / gate + runs-on: ubuntu-latest + steps: + - name: Determine required Status from target branch + id: target + env: + BASE: ${{ github.base_ref }} + run: | + case "$BASE" in + staging) echo "required=On dev" >> "$GITHUB_OUTPUT" ;; + main|master) echo "required=Ready for prod" >> "$GITHUB_OUTPUT" ;; + *) echo "required=" >> "$GITHUB_OUTPUT" ;; + esac + + - name: Skip if not promoting to staging/main/master + if: steps.target.outputs.required == '' + run: echo "Target branch '${{ github.base_ref }}' is not gated — nothing to enforce." + + - name: Check for skip-fr-gate label + id: skip + if: steps.target.outputs.required != '' + env: + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + if echo "$LABELS" | grep -q '"skip-fr-gate"'; then + echo "skip=true" >> "$GITHUB_OUTPUT" + echo "::warning::FR gate bypassed via 'skip-fr-gate' label." + # (promotion-shape guard below is also bypassed by this label) + else + echo "skip=false" >> "$GITHUB_OUTPUT" + fi + + - name: Promotion-shape guard — only the train promotes + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }} + run: | + # Manual promotion PRs bypass the release train's Bugbot soft-gate, + # its tagging, and its run records — and indirect-merge the train's + # own PRs (backend#1336 / averaging-service, 2026-07-29). Promotions + # into staging/main/master are the train's job. Sanctioned lanes: + # release-train/* the train's own mirror PRs + # hotfix-backmerge/* hotfix.yml's automated back-merges + # 'hotfix' label single-repo emergency fix (D14a, audited) + # 'skip-fr-gate' label full gate override (handled above, audited) + case "$HEAD_REF" in + release-train/*|hotfix-backmerge/*) exit 0 ;; + esac + if echo "$LABELS" | grep -q '"hotfix"'; then + exit 0 + fi + echo "::error::Manual promotion PRs are retired — promotions into this branch go through the release train (tracebloc/release-train → Actions → 'Release train'). For a single-repo emergency prod fix use the 'hotfix' label; 'skip-fr-gate' remains the audited full override." + exit 1 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + # THE WALK LIVES HERE, NOT IN THE CALLING REPO (backend#3323, RFC-0075 D6). + # The two steps below used to carry it inline; the release train needs the + # same walk to cut the prod hop at the FR frontier, and a second copy in + # release-train is the drift rule 9 forbids. So the script is checked out + # of tracebloc/org-config -- same shape as bugbot-gate.yml and + # code-quality.yml -- into a path beside the caller's checkout. org-config + # is PRIVATE (it replaced the public `.github` as the host on 2026-09-10), + # so the checkout carries a read-only App installation token minted just + # for it; the App id and key arrive because every fr-gate caller passes + # `secrets: inherit` (this reusable minted an App token long before the + # move). `persist-credentials: false` because the walk itself reads with + # the wider App token minted further below, never with the checkout's. + - name: Mint a read-only token for org-config (private; the caller's own token cannot read it) + id: org-config-token + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 + + - name: Check out the shared walk script + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tracebloc/org-config + token: ${{ steps.org-config-token.outputs.token }} + ref: ${{ 'main' }} + path: .fr-gate-tools + persist-credentials: false + + # Authenticates as the tracebloc-release-train App (backend#2036) instead of a + # human's PAT. `owner:` yields an ORG-scoped installation token, which this gate + # needs: it reads the org PROJECT, and a repo-scoped token cannot. + # + # READ-ONLY, and that is the whole reason this gate could move. It reads + # `commits/{sha}/pulls`, `compare/{base}...{head}` and the ProjectV2 graphql -- + # every one covered by permissions the App already holds. It never writes a + # card, a label or a comment; the gate's only output is its own conclusion. + # + # Gated behind the same `if:` as the steps that use it, so a PR this gate skips + # (not promoting, or `skip-fr-gate`) mints nothing. + # + # NO FALLBACK TO THE PAT: a fallback would let a broken App path keep working + # silently, and this gate blocking wrongly is far better than it passing wrongly. + - name: Mint an installation token + id: app-token + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} + private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + # THREE READS AND NOTHING ELSE (backend#2157). Without these it minted + # the App's whole set -- contents:write, issues:write, + # organization_projects:write, administration:read -- across every repo + # the installation covers, to run a gate that writes nothing at all. + # A pure reader holding org-wide write is the sharpest mismatch of the + # four this ticket found. + # + # NOT DERIVED FROM A TEMPLATE -- from the three calls the comment above + # already names, which is why it could be checked rather than guessed: + # commits/{sha}/pulls -> contents: read + pull-requests: read + # compare/{base}...{head} -> contents: read + # ProjectV2 graphql -> organization-projects: read + # + # `actions/checkout` above is NOT on this token -- it takes the job's + # default GITHUB_TOKEN, so nothing here needs contents:write for the + # checkout either. + # + # IF THIS SCOPE IS SHORT the gate fails closed and blocks promotions, + # which is loud, immediate and recoverable with the `skip-fr-gate` + # label. That is the failure direction this gate already chose for + # itself: "blocking wrongly is far better than passing wrongly". + permission-contents: read + permission-pull-requests: read + permission-organization-projects: read + + - name: Discover items in this promotion + id: items + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + env: + BASE_REF: ${{ github.base_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + REPO_FULL: ${{ github.repository }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + # Make the base branch tip available locally for the diff. + git fetch origin "$BASE_REF":"refs/remotes/origin/$BASE_REF" --depth=200 2>/dev/null || true + BASE_SHA=$(git rev-parse "origin/$BASE_REF") + export BASE_SHA + # Commit->PR attribution, the promotion-PR transparency, the two-pass + # merge handling (Bugbot #72/#73) and the three-dot file count all live + # in the script's header and body -- one implementation, shared with the + # train's frontier resolver. It writes prs= / unattributed= / + # changed_files= to $GITHUB_OUTPUT exactly as this step always did. + bash .fr-gate-tools/scripts/fr-gate-walk.sh discover + + - name: Verify each item is in required Status + if: steps.target.outputs.required != '' && steps.skip.outputs.skip != 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ORG: ${{ 'tracebloc' }} + PROJECT_NUMBER: ${{ 2 }} + REPO_FULL: ${{ github.repository }} + REQUIRED: ${{ steps.target.outputs.required }} + PRS: ${{ steps.items.outputs.prs }} + UNATTRIB: ${{ steps.items.outputs.unattributed }} + PROMOTION_PR: ${{ github.event.pull_request.number }} + CHANGED_FILES: ${{ steps.items.outputs.changed_files }} + run: | + # The rank() ordering, resolve_status's race-aware retries and the + # fail-closed verdict (blocked / missing / unreadable / unattributable + # all block) are the script's `verify` mode. Same text, same exit code. + bash .fr-gate-tools/scripts/fr-gate-walk.sh verify From 8357bc39eb17c8eb216a498bac14f452892e18f0 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:08 +0200 Subject: [PATCH 06/12] chore(onboard): add .github/workflows/fr-pass-comment.yml (content profile) --- .github/workflows/fr-pass-comment.yml | 612 ++++++++++++++++++++++++++ 1 file changed, 612 insertions(+) create mode 100644 .github/workflows/fr-pass-comment.yml diff --git a/.github/workflows/fr-pass-comment.yml b/.github/workflows/fr-pass-comment.yml new file mode 100644 index 0000000..5ca4e5d --- /dev/null +++ b/.github/workflows/fr-pass-comment.yml @@ -0,0 +1,612 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/fr-pass-comment.yml` +# (blob 5b74b698d095e29a4a20f50d64576029bab31860) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/fr-pass-comment-caller.yml` (blob bc01ef34bf604788e80a0b19d31b8fe7edfaae15), 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 tracebloc/org-config. 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 org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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: FR pass comment handler + +# Reusable workflow. Called from each active repo on issue_comment created. +# Listens for "/fr-pass" comments on PRs/issues that are currently in +# "FR on staging" and advances them one column: +# FR on staging → Ready for prod +# (D6: the dev-side review is gone — "On dev" is automatic — so /fr-pass only +# applies at staging, the single functional-review gate.) +# +# NEVER NO-OP IN SILENCE (backend#1319) +# Every terminating path below leaves a signal on the thread: 👍 when the card +# moved, 👎 plus a one-paragraph comment saying why when it did not. The single +# deliberate exception is a comment that merely *mentions* the string without +# issuing the command ("/fr-passport", "you can /fr-pass it later") — reacting +# to prose would turn the bot into noise. +# +# The rule that follows from that: no decision may live in the job-level `if`. +# A false job `if` skips the job with zero steps, so there is nothing left to +# react or comment with — the run just reports `skipped` and the reviewer +# believes the card moved. The job `if` is therefore only a cheap "is this even +# about /fr-pass" filter; authorisation, self-signoff, board membership and the +# column check all happen in steps that can report. +# +# A REFUSAL IS NOT A SUCCESS (backend#1413) +# Exactly three outcomes exit 0 — `advanced`, `already-advanced` and +# `not-a-command`. EVERY other terminating path exits 1, including +# `not-authorised`, `self-signoff`, `not-on-project`, `ambiguous-item`, +# `no-status` and `wrong-column`, where the handler worked perfectly and +# correctly declined to move the card. +# +# Failing a deliberate refusal reads oddly, and it is the point. `gh run list` +# is the only place anyone reads many threads at once, and green there means +# "the card moved". While those six exited 0, 26 green runs concealed three +# cards that had not moved; the refusal was diagnosed as a bug in the advance +# logic and the cards were then moved by hand through the project API — +# bypassing the control rather than satisfying it. The 👎 and its note stay the +# real explanation; a red run is just the cheapest signal that one exists. +# +# WHY A COMMENT GATE +# It leaves a record on the thread, so reviewers can see who passed FR and +# when. Dragging the card on the kanban works too — this is the shortcut. + +on: + issue_comment: + types: [created] + +# GITHUB_TOKEN stays read-only and is NOT used for any write here. Two reasons it +# cannot be: the org default is `default_workflow_permissions: read`, and a called +# workflow can only narrow the caller job's token, never widen it — so a +# `permissions: issues: write` block on this workflow would be silently +# ineffective while looking like it worked. That is exactly how the 👍/👎 reaction +# came to fail with "Resource not accessible by integration (HTTP 403)" on every +# run, including the ones that advanced their card successfully. All writes +# therefore go through PROJECTS_KANBAN_TOKEN, which already has cross-repo write +# (wip-limit-check.yml posts PR comments with it). The block below pins GITHUB_TOKEN +# to contents:read and nothing else, which is also why there is no GITHUB_TOKEN +# fallback on the writes: it would be dead code that logs a misleading retry. +permissions: + contents: read + +jobs: + advance: + name: advance / advance + # The job-level conditions are a cheap "is this even about /fr-pass" filter + # and nothing more; no decision lives here (see NEVER NO-OP IN SILENCE). + # + # The first is deliberately loose — GitHub's contains() is a case-insensitive + # substring test, so "/FR-PASS", leading whitespace and trailing text all get + # through. Loose is the point: a comment that gets in but turns out not to be + # the command exits silently from the first step, whereas a genuine command + # that never gets in can never be reported. The precise match lives in the + # step. + # + # The other two stop this handler answering itself (backend#1413). Every + # refusal note below contains the literal string `/fr-pass`, so posting one + # re-fired this workflow — 6 of the 26 runs on 2026-08-01 were the bot reading + # its own comment. That only ever terminated because the step's anchored grep + # rejects a line starting with an emoji, which is luck, not a design: reflow a + # note so `/fr-pass` lands first on its line and it loops until the concurrency + # limits bite. + # + # * Not a bot account. This is the guard the loop *should* have needed, and + # today it catches nothing — the writes use PROJECTS_KANBAN_TOKEN, a PAT + # belonging to a human, so the handler's own comments arrive under that + # person's login, indistinguishable from their real reviews. Keyed on the + # account type rather than a login for exactly that reason: hard-coding + # the login would lock out the busiest reviewer, and moving the token to a + # GitHub App later makes this clause the whole fix on its own. + # * Not one of our own notes. This is what actually breaks the loop, against + # a marker the reporting step prepends to every note it posts, so it holds + # however the wording is later reflowed. startsWith, not contains, and the + # marker goes first for that reason: GitHub's "Quote reply" prefixes "> ", + # so a human who quotes a refusal and issues the command underneath is + # still heard. Skipping a verbatim note of our own costs nothing — there is + # nobody to report to, and 👎-ing our own comment is precisely the noise the + # not-a-command exception exists to avoid. + if: >- + contains(github.event.comment.body, '/fr-pass') + && github.event.comment.user.type != 'Bot' + && !startsWith(github.event.comment.body, '') + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # Authenticates as the tracebloc-release-train App (backend#2036) instead of + # one human's PAT. Needs BOTH grants: `organization_projects: write` to advance + # the card and `issues: write` to post the outcome comment and its reaction. + # `owner:` makes the token ORG-scoped -- a repo-scoped one cannot reach an org + # ProjectV2. No fallback to the old PAT: a fallback would let a broken App path + # look like a working migration. + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 (backend#2157). + # Two steps share this token and between them make six calls; this is the + # union, with the permission each one documents: + # + # GET repos/{r}/collaborators/{u}/permission Metadata read (implicit) + # GET repos/{r}/pulls/{n} PRs read + # organization().projectV2 + item Status read projects read + # updateProjectV2ItemFieldValue projects WRITE + # POST issues/comments/{id}/reactions Issues WRITE <- only option + # POST issues/{n}/comments Issues write (or PRs write) + # + # `issues: write` is what forces a write grant here, and it is not + # substitutable: GitHub documents the issue-comment REACTION endpoint as + # Issues write ALONE, while the comment endpoint accepts either. Both + # endpoints serve PR threads too -- a PR's conversation IS an issue -- so + # `pull-requests` stays at read, needed only for the promotion-PR ref + # lookup in step 3a. + # + # `Metadata` is granted implicitly alongside any repository permission, + # which is why the collaborator-permission read needs no row of its own. + # Note what happens if that call fails anyway: the step warns and falls + # back to `author_association`, a LOOSER authorisation test. So a scope + # error here degrades security rather than reddening -- if the warning + # "Could not read repository permission" starts appearing in these runs, + # treat it as this change's regression, not as noise. + # + # Nothing reads or writes repository content, so contents drops. + permission-issues: write + permission-pull-requests: read + permission-organization-projects: write + + - name: Decide the outcome and advance the card + id: assess + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ORG: ${{ 'tracebloc' }} + PROJECT_NUMBER: ${{ 2 }} + ALLOW_SELF_SIGNOFF: ${{ true }} + REPO_FULL: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + IS_PR: ${{ github.event.issue.pull_request != null }} + ITEM_AUTHOR: ${{ github.event.issue.user.login }} + ACTOR: ${{ github.event.comment.user.login }} + ASSOCIATION: ${{ github.event.comment.author_association }} + # Via the environment, never interpolated into the script: a comment + # body is attacker-controlled text and must not reach the shell parser. + COMMENT_BODY: ${{ github.event.comment.body }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + BOARD_URL: ${{ github.server_url }}/orgs/${{ 'tracebloc' }}/projects/${{ 2 }} + run: | + set -euo pipefail + + RESULT_FILE="$RUNNER_TEMP/fr-pass.result" + NOTE_FILE="$RUNNER_TEMP/fr-pass.note.md" + : > "$RESULT_FILE" + : > "$NOTE_FILE" + + # A literal ``` fence, kept in a variable so no backtick ever has to be + # escaped inside a command substitution below. + FENCE='```' + + # Publish an outcome on EVERY exit, including an unplanned one. The + # reporting step keys off this one output, so a crash halfway through + # (API error, jq error, a `set -e` trip) still produces a 👎 and an + # explanation rather than the silence reported in backend#1319. + publish() { + if [ ! -s "$RESULT_FILE" ]; then + printf 'internal-error' > "$RESULT_FILE" + printf '%s\n' \ + "⚠️ \`/fr-pass\` failed before it could decide anything, so the card was **not** moved." \ + "" \ + "This is a bug in the handler, not something you did. Run log: $RUN_URL" > "$NOTE_FILE" + fi + echo "result=$(cat "$RESULT_FILE")" >> "$GITHUB_OUTPUT" + } + trap publish EXIT + + # decide [note-line...] — record the outcome, plus the comment + # to post with it. No note lines = react only, no thread comment. + decide() { + printf '%s' "$1" > "$RESULT_FILE" + shift + if [ "$#" -gt 0 ]; then + printf '%s\n' "$@" > "$NOTE_FILE" + fi + } + + # ------------------------------------------------ 1. is it a command? + # /fr-pass must be the first token on some line of the comment. Leading + # whitespace and trailing text are fine; a quoted reply ("> /fr-pass") + # or an inline mention is not the command and must not re-trigger it. + BODY_FILE="$RUNNER_TEMP/fr-pass.body" + printf '%s' "$COMMENT_BODY" | tr -d '\r' > "$BODY_FILE" + if ! grep -qiE '^[[:space:]]*/fr-pass([[:space:]].*)?$' "$BODY_FILE"; then + echo "Body mentions /fr-pass but does not issue it as a command — staying silent by design." + decide not-a-command + exit 0 + fi + + # --------------------------------------------- 2. may they sign off? + # author_association is not a permission check: a member whose org + # membership is private reads as CONTRIBUTOR (or NONE) on repos they + # were not added to individually, so the old MEMBER/OWNER test would + # silently reject a real reviewer. Ask for the actual repository + # permission, which also states D30's intent — "not a triage/read + # outside collaborator" — directly instead of approximating it. + PERM_ERR="$RUNNER_TEMP/fr-pass.perm.err" + PERM_JSON=$(gh api "repos/$REPO_FULL/collaborators/$ACTOR/permission" 2> "$PERM_ERR") || PERM_JSON="" + PERM=$(printf '%s' "$PERM_JSON" | jq -r '.permission // empty' 2> /dev/null || true) + ROLE=$(printf '%s' "$PERM_JSON" | jq -r '.role_name // empty' 2> /dev/null || true) + + AUTHORISED=false + case "$PERM" in admin | maintain | write) AUTHORISED=true ;; esac + case "$ROLE" in admin | maintain | write) AUTHORISED=true ;; esac + + if [ -z "$PERM_JSON" ]; then + # API unreachable (scope, rate limit, outage). Fall back to the + # association rather than reject a legitimate reviewer — and say so. + echo "::warning::Could not read repository permission for @$ACTOR — falling back to author_association=$ASSOCIATION" + sed -n '1,3p' "$PERM_ERR" + case "$ASSOCIATION" in MEMBER | OWNER | COLLABORATOR) AUTHORISED=true ;; esac + fi + + if [ "$AUTHORISED" != "true" ]; then + decide not-authorised \ + "🚫 \`/fr-pass\` was **not** applied: @$ACTOR does not have write access to \`$REPO_FULL\`." \ + "" \ + "Functional sign-off is limited to people who can write to the repository (seen: permission \`${PERM:-unknown}\`, association \`$ASSOCIATION\`). Ask someone on the team to sign off, or move the card by hand on the [engineering kanban]($BOARD_URL)." + exit 1 + fi + + # -------------------------- 3a. is this a release-train promotion? + # Promotion PRs (`release-train/*`, and hotfix back-merges) are + # plumbing: the FR gate on them evaluates the CONTAINED items, so the + # promotion PR itself has nothing for a second reviewer to assess and + # D30 would only block the train on its own author. The comment event + # carries no branch refs, so look them up; FAIL CLOSED — an + # unreadable PR is treated as normal work and keeps the D30 rule. + IS_PROMOTION=false + if [ "$IS_PR" = "true" ]; then + if REFS=$(gh api "repos/$REPO_FULL/pulls/$NUMBER" \ + --jq '[.head.ref, .base.ref] | @tsv' 2>/dev/null); then + HEAD_REF=$(printf '%s' "$REFS" | cut -f1) + BASE_REF=$(printf '%s' "$REFS" | cut -f2) + case "$HEAD_REF" in + release-train/* | hotfix-backmerge/*) + # Belt and braces: those heads only ever target an + # integration branch, so require that too. + case "$BASE_REF" in + staging | main | master) IS_PROMOTION=true ;; + esac + ;; + esac + else + echo "Could not read the PR refs for #$NUMBER; treating it as normal work (D30 stays in force)." + fi + fi + + # ----------------------------------------- 3. self sign-off (D30) + # THE bug from backend#1319 lived here, as a clause in the job-level + # `if`: when the reviewer was also the PR author the whole job was + # skipped, so no step was left to react or explain. 8 of the 13 + # /fr-pass comments on 2026-07-29 died exactly this way. The rule is + # unchanged; what changed is that it now says so out loud. + if [ "$ACTOR" = "$ITEM_AUTHOR" ] \ + && [ "$ALLOW_SELF_SIGNOFF" != "true" ] \ + && [ "$IS_PROMOTION" != "true" ]; then + decide self-signoff \ + "👀 \`/fr-pass\` was **not** applied: functional review needs a second pair of eyes, and @$ACTOR opened this one (D30)." \ + "" \ + "Ask another team member to comment \`/fr-pass\`, or move the card to **Ready for prod** on the [engineering kanban]($BOARD_URL) if you are deliberately overriding the rule." \ + "" \ + "_Release-train promotion PRs are exempt from this rule automatically. To allow self sign-off on everything org-wide, set \`allow-self-signoff: true\` on \`fr-pass-comment.yml\`._" + exit 1 + fi + + # ----------------------------------------- 4. resolve the board item + REPO_NAME="${REPO_FULL#*/}" + + # One query, two roots: project metadata + this item's Status. + # $itemNum names the issue/PR number so it stays distinct from $num. + if [ "$IS_PR" = "true" ]; then + # shellcheck disable=SC2016 # $itemNum is a GraphQL variable - must not expand in shell + CONTENT_QUERY='pullRequest(number: $itemNum)' + else + # shellcheck disable=SC2016 # $itemNum is a GraphQL variable - must not expand in shell + CONTENT_QUERY='issue(number: $itemNum)' + fi + + GQL_ERR="$RUNNER_TEMP/fr-pass.gql.err" + # No `2>/dev/null || PROJ='{}'` here. Swallowing the error made an + # expired token, a rate limit and a genuinely unlisted card all produce + # the same "not on project" 👎 — undiagnosable from the thread. + if ! PROJ=$(gh api graphql -f query=" + query(\$org: String!, \$num: Int!, \$repo: String!, \$itemNum: Int!) { + organization(login: \$org) { + projectV2(number: \$num) { + id + field(name: \"Status\") { + ... on ProjectV2SingleSelectField { id options { id name } } + } + } + } + repository(owner: \$org, name: \$repo) { + $CONTENT_QUERY { + projectItems(first: 20) { + totalCount + nodes { + id + project { number } + fieldValueByName(name: \"Status\") { + ... on ProjectV2ItemFieldSingleSelectValue { name } + } + } + } + } + } + }" -F org="$ORG" -F num="$PROJECT_NUMBER" -F repo="$REPO_NAME" -F itemNum="$NUMBER" 2> "$GQL_ERR"); then + GQL_HEAD=$(sed -n '1,5p' "$GQL_ERR" | tr -d "$FENCE") + decide api-error \ + "⚠️ \`/fr-pass\` could **not** be applied: the kanban API call failed, so the card was left alone." \ + "" \ + "$FENCE" \ + "$GQL_HEAD" \ + "$FENCE" \ + "" \ + "Usually an expired \`PROJECTS_KANBAN_TOKEN\` or a transient GitHub error. Re-comment \`/fr-pass\` to retry. Run log: $RUN_URL" + exit 1 + fi + + GQL_ERRORS=$(printf '%s' "$PROJ" | jq -r '(.errors // []) | map(.message) | join("; ")') + PROJECT_ID=$(printf '%s' "$PROJ" | jq -r '.data.organization.projectV2.id // empty') + STATUS_FIELD=$(printf '%s' "$PROJ" | jq -r '.data.organization.projectV2.field.id // empty') + + if [ -n "$GQL_ERRORS" ] || [ -z "$PROJECT_ID" ] || [ -z "$STATUS_FIELD" ]; then + decide api-error \ + "⚠️ \`/fr-pass\` could **not** be applied: project #$PROJECT_NUMBER or its \`Status\` field did not resolve, so the card was left alone." \ + "" \ + "GraphQL said: \`${GQL_ERRORS:-no error message}\`" \ + "" \ + "Run log: $RUN_URL" + exit 1 + fi + + # Take whichever content root the query returned, then keep only the + # items that belong to OUR project. + NODES=$(printf '%s' "$PROJ" | jq -c --arg n "$PROJECT_NUMBER" ' + [ ((.data.repository // {}) | (.issue // .pullRequest // {}) | .projectItems.nodes // [])[] + | select(.project.number == ($n | tonumber)) ]') + TOTAL_ITEMS=$(printf '%s' "$PROJ" | jq -r ' + ((.data.repository // {}) | (.issue // .pullRequest // {}) | .projectItems.totalCount) // 0') + ITEM_COUNT=$(printf '%s' "$NODES" | jq -r 'length') + + if [ "$ITEM_COUNT" = "0" ]; then + # A truncated page is not proof of absence: say which case this is. + if [ "$TOTAL_ITEMS" -gt 20 ]; then + decide api-error \ + "⚠️ \`/fr-pass\` could **not** be applied: this item is on $TOTAL_ITEMS projects and the handler reads only the first 20, so project #$PROJECT_NUMBER may have been cut off." \ + "" \ + "Raise the \`projectItems(first: 20)\` page size in \`fr-pass-comment.yml\`. Run log: $RUN_URL" + exit 1 + fi + decide not-on-project \ + "🔍 \`/fr-pass\` did nothing: this item is not on the [engineering kanban]($BOARD_URL) (project #$PROJECT_NUMBER), so there is no card to advance." \ + "" \ + "\`add-to-kanban.yml\` normally adds every new issue and PR. If it was missed, add the card, set **FR on staging**, then comment \`/fr-pass\` again. If the tracked work lives on a linked issue rather than this PR, sign off there instead." + exit 1 + fi + + if [ "$ITEM_COUNT" != "1" ]; then + # A single piece of content can only sit on a project once, so this + # means the board data is odd. Do not guess which card was meant. + decide ambiguous-item \ + "⚠️ \`/fr-pass\` did nothing: this item resolves to $ITEM_COUNT separate cards on project #$PROJECT_NUMBER, so the handler will not guess which one to advance." \ + "" \ + "Clean up the duplicate on the [engineering kanban]($BOARD_URL) and comment \`/fr-pass\` again." + exit 1 + fi + + ITEM_ID=$(printf '%s' "$NODES" | jq -r '.[0].id') + CURRENT=$(printf '%s' "$NODES" | jq -r '.[0].fieldValueByName.name // ""') + + # The two column names below are hard-coded, and this board has been + # renamed before (RFC-BACKEND-0008 collapsed the dev-side FR columns). + # Check they still exist, so the next rename fails loudly here instead + # of reporting "wrong column" for every card on the board. + # Rename window (backend#1592): the staging-review column is "Staging + # (human review)" after the UI rename and "FR on staging" before it, and + # the rename is a single instant with no overlap. So resolve it to + # whichever exists and keep the loud failure for the case where NEITHER + # does -- which is the check this block was written for. + if printf '%s' "$PROJ" | jq -e '.data.organization.projectV2.field.options[] + | select(.name == "Staging (human review)")' > /dev/null; then + STAGING_REVIEW="Staging (human review)" + else + STAGING_REVIEW="FR on staging" + fi + for WANT in "$STAGING_REVIEW" "Ready for prod"; do + if ! printf '%s' "$PROJ" \ + | jq -e --arg s "$WANT" '.data.organization.projectV2.field.options[] | select(.name == $s)' > /dev/null; then + decide missing-option \ + "⚠️ \`/fr-pass\` could **not** be applied: project #$PROJECT_NUMBER has no \`Status\` option named **$WANT**, so the card was left alone." \ + "" \ + "The board's columns were probably renamed. Update the column names in \`.github/.github/workflows/fr-pass-comment.yml\`. Run log: $RUN_URL" + exit 1 + fi + done + + # ------------------------------------------------------ 5. the column + case "$CURRENT" in + "Staging (human review)"|"FR on staging") + NEXT="Ready for prod" + ;; + "Ready for prod" | "Prod") + decide already-advanced \ + "✅ Nothing to do: this card is already in **$CURRENT**, past the staging functional review." \ + "" \ + "No change made — you can ignore this." + # Stays 0: the gate is already satisfied, so the caller got the + # state they asked for. Nothing is left for a human to do, which is + # the only thing a red run is here to say. + exit 0 + ;; + "") + decide no-status \ + "🔍 \`/fr-pass\` did nothing: this card is on the board but its \`Status\` is empty, so there is no column to advance from." \ + "" \ + "Set it to **FR on staging** on the [engineering kanban]($BOARD_URL) and comment \`/fr-pass\` again." + exit 1 + ;; + *) + decide wrong-column \ + "🔍 \`/fr-pass\` did nothing: this card is in **$CURRENT**, and \`/fr-pass\` only advances **FR on staging → Ready for prod** (the one functional-review gate)." \ + "" \ + "Promote it to **FR on staging** first — that happens automatically when the release train pushes to \`staging\`." + exit 1 + ;; + esac + + NEXT_OPT=$(printf '%s' "$PROJ" | jq -r --arg s "$NEXT" \ + '.data.organization.projectV2.field.options[] | select(.name == $s) | .id') + + # Pass the option ID with -f (raw string), NOT -F: ProjectV2 option IDs can + # be all-numeric, and -F coerces all-digit values to an integer, which the + # $o: String! variable rejects. -f forces a string. (Some option IDs contain + # letters today, but don't rely on that — IDs regenerate if recreated.) + MUT_ERR="$RUNNER_TEMP/fr-pass.mutate.err" + # shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal + if ! 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="$STATUS_FIELD" -f o="$NEXT_OPT" > /dev/null 2> "$MUT_ERR"; then + MUT_HEAD=$(sed -n '1,5p' "$MUT_ERR" | tr -d "$FENCE") + decide mutation-failed \ + "⚠️ \`/fr-pass\` could **not** be applied: the card is still in **$CURRENT** — the write to project #$PROJECT_NUMBER failed." \ + "" \ + "$FENCE" \ + "$MUT_HEAD" \ + "$FENCE" \ + "" \ + "Re-comment \`/fr-pass\` to retry. Run log: $RUN_URL" + exit 1 + fi + + echo "→ #$NUMBER: $CURRENT → $NEXT" + decide advanced + { + echo "from=$CURRENT" + echo "to=$NEXT" + } >> "$GITHUB_OUTPUT" + + - name: Report the outcome (👍 / 👎 + why) + # always(), with no `steps.*` condition: if the step above died before it + # could publish anything, THAT is the case most in need of reporting. + if: always() + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO_FULL: ${{ github.repository }} + NUMBER: ${{ github.event.issue.number }} + COMMENT_ID: ${{ github.event.comment.id }} + RESULT: ${{ steps.assess.outputs.result }} + FROM: ${{ steps.assess.outputs.from }} + TO: ${{ steps.assess.outputs.to }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + set -uo pipefail + + NOTE_FILE="$RUNNER_TEMP/fr-pass.note.md" + OUTCOME="${RESULT:-}" + if [ -z "$OUTCOME" ]; then + # The step above produced no output at all — it was cancelled, or it + # died before its EXIT trap could run. Still report something. + OUTCOME="internal-error" + if [ ! -s "$NOTE_FILE" ]; then + printf '%s\n' \ + "⚠️ The \`/fr-pass\` handler stopped before deciding anything, so the card was **not** moved." \ + "" \ + "Run log: $RUN_URL" > "$NOTE_FILE" + fi + fi + + # The one intentionally silent outcome: the comment mentioned /fr-pass + # but never issued it. Reacting to prose would make the bot noise. + if [ "$OUTCOME" = "not-a-command" ]; then + echo "Not a /fr-pass command — no reaction, by design." + echo "### /fr-pass: not a command (no action taken)" >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + + if [ "$OUTCOME" = "advanced" ]; then + REACTION="+1" + SUMMARY="advanced #$NUMBER: $FROM → $TO" + else + REACTION="-1" + SUMMARY="no-op on #$NUMBER ($OUTCOME)" + fi + + FAILED="" + + # Reaction. The old version used GITHUB_TOKEN and hid the outcome behind + # `|| true`, so every attempt died on HTTP 403 in silence — even the runs + # that DID advance the card. PROJECTS_KANBAN_TOKEN is the only token here + # that can write at all (see the permissions note at the top of the + # file), so there is no second token to fall back to — a failure is + # reported and fails the run instead of being swallowed. + REACT_ERR="$RUNNER_TEMP/fr-pass.react.err" + if ! gh api -X POST "/repos/$REPO_FULL/issues/comments/$COMMENT_ID/reactions" \ + -f content="$REACTION" > /dev/null 2> "$REACT_ERR"; then + echo "::error::Could not react on comment $COMMENT_ID." + sed -n '1,6p' "$REACT_ERR" + FAILED="reaction" + fi + + # Explanation. Only outcomes that are a real problem carry a note; a + # clean advance stays a bare 👍 so the happy path keeps the thread quiet. + if [ -s "$NOTE_FILE" ]; then + # Prepend the marker the job `if` tests for, so this handler can + # recognise its own notes and not answer them (backend#1413). Added + # here, at the single place a note is posted, rather than in each + # `decide` call above — a note that forgets it would resurrect the + # loop. First line, because the job `if` uses startsWith. HTML + # comments are stripped when GitHub renders markdown but kept in the + # stored body the event carries, so the thread reads as it did before. + BODY=$(printf '%s\n\n%s\n' '' "$(cat "$NOTE_FILE")") + COMMENT_ERR="$RUNNER_TEMP/fr-pass.comment.err" + # REST, not `gh pr/issue comment`: a PR's conversation IS an issue, so + # this one endpoint covers both without branching on the item type. + if ! gh api -X POST "/repos/$REPO_FULL/issues/$NUMBER/comments" \ + -f body="$BODY" > /dev/null 2> "$COMMENT_ERR"; then + echo "::error::Could not post the explanation on #$NUMBER." + sed -n '1,6p' "$COMMENT_ERR" + FAILED="$FAILED comment" + fi + fi + + { + echo "### /fr-pass: $SUMMARY" + echo "" + echo "- outcome: \`$OUTCOME\`" + echo "- reaction: $REACTION on comment $COMMENT_ID" + } >> "$GITHUB_STEP_SUMMARY" + + # A write that failed must not leave a green run behind it — a red run + # is the last remaining signal once the thread could not be reached. + if [ -n "$FAILED" ]; then + echo "::error::/fr-pass could not report its outcome ($FAILED). Outcome was: $OUTCOME" + exit 1 + fi + + echo "/fr-pass → $OUTCOME (reacted $REACTION)" From ffd2802cef93e55c46a5eeb79a88dd883add44c4 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:10 +0200 Subject: [PATCH 07/12] chore(onboard): add .github/workflows/kanban-closure-router.yml (content profile) --- .github/workflows/kanban-closure-router.yml | 577 ++++++++++++++++++++ 1 file changed, 577 insertions(+) create mode 100644 .github/workflows/kanban-closure-router.yml diff --git a/.github/workflows/kanban-closure-router.yml b/.github/workflows/kanban-closure-router.yml new file mode 100644 index 0000000..0d57ef2 --- /dev/null +++ b/.github/workflows/kanban-closure-router.yml @@ -0,0 +1,577 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/kanban-closure-router.yml` +# (blob 7ffe4b4c69900fa358142def93c524667350cdda) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/kanban-closure-caller.yml` (blob e78122f771274190cbfcc450ccfce7ada2ff077a), 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 tracebloc/org-config. 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 +# `route / ` prefix so the check contexts branch protection requires do +# not change. Scripts are still fetched from org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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: Route kanban Status on closure + +# Reusable workflow. Called on PR closed + issue closed events. +# Sets the correct Status based on what actually happened: +# - PR merged to develop → On dev (automatic; no dev-side review — D6) +# - PR merged to staging → FR on staging (functional review on staging environment) +# - PR merged to main/master → Prod (shipped to prod) +# - PR closed without merging → Cancelled +# - Issue closed as completed → Done (terminal; how it was closed is irrelevant) +# (was: mirror the closing PR's Status, which put finished issues in deploy +# columns — see the branch below and backend#2722) +# - Issue closed as not_planned → Cancelled +# - Issue closed (no state_reason) → Cancelled (default to abandoned) +# +# NOTE: For PR merges, advance-deploy-env.yml also fires on the resulting branch +# push and sets the same Status. Both workflows are idempotent and converge on +# the same value; this one fires faster (PR close event) and serves as the +# primary signal, while advance-deploy-env covers the case of pushes that +# weren't a PR merge (e.g. fast-forward of develop → staging). + +on: + pull_request: + types: [closed] + issues: + types: [closed] + +jobs: + route: + name: route / route + runs-on: ubuntu-latest + steps: + # The mapping comes from .github, not from a copy in this file: one + # definition of branch -> Status (backend#2243). + - 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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tracebloc/org-config + token: ${{ steps.org-config-token.outputs.token }} + ref: main + path: .kanban-map + persist-credentials: false + + # Authenticates as the tracebloc-release-train App (backend#2036) instead of + # one human's PAT. Needs both grants: `organization_projects: write` for the + # card, `issues: write` for the sibling-merge label and the closure comment. + # `owner:` makes the token ORG-scoped -- a repo-scoped one cannot reach an org + # ProjectV2, and the closer lookup below is cross-repo besides. + # + # No fallback to the old PAT: a fallback would let a broken App path look like + # a working migration, which is the defect class backend#1680 exists to remove. + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 (backend#2157). + # Four steps share this token; this is the union of what they call: + # + # organization().projectV2 fields/options projects read + # repository.pullRequest|issue(n).projectItems PRs+issues read + # updateProjectV2ItemFieldValue projects WRITE + # gh issue comment (the D8 parked-card note) Issues WRITE + # GET repos/{r}/labels/{name} (does it exist?) Issues read + # POST repos/{r}/labels (create it) Issues WRITE + # POST repos/{r}/issues/{n}/labels Issues write + # + # The last one lands on a PULL REQUEST in the sibling-merge case, and it + # still only needs `issues: write`: GitHub documents the label endpoints + # as "at least one of" Issues write / Pull requests write, and every + # shared action on a PR -- labels, assignees, milestones -- is served by + # the Issues endpoints. So `pull-requests` stays at READ, which is all the + # `projectItems` lookup on a PR needs. + # + # `contents: READ` IS REQUIRED, and the previous revision of this comment + # got it wrong in a way worth recording. It proved contents:WRITE could + # drop and then concluded nothing reads content either -- in the same + # sentence that names the read. `branch_status_map.py` fetches the + # `.kanban.yml` override over the API (`FETCHED, NOT READ OFF DISK, + # because the router never checks the repo out`) and is passed + # `$REPO_FULL` -- the CALLER's repo, not this one -- with + # `GH_TOKEN: steps.app-token.outputs.token`. `persist-credentials: false` + # applies to the `.github` checkout, which is not the repo being read. + # + # AND IT WOULD NOT HAVE FAILED LOUDLY. The mapper's own contract treats + # 404 as "no override" but REFUSES on any other fetch failure, and the + # call sites below wrap it in `if ! STATUS=$(...)` which falls to the + # holding state by design. So a 403 parks EVERY merged PR at + # `override-unusable` with a green job -- silent mis-routing fleet-wide. + # Found by Bugbot and confirmed by saadqbal on .github#324. + # + # contents:write still drops, as do administration/actions/checks read. + # + # `repositories:` STAYS UNNARROWED -- the cross-repo callers are the + # projectItems reads and the issue comment/label writes listed above (a + # client or website PR routinely closes a `backend` issue), and + # `organization-projects` is an org-level grant regardless. The closer + # lookup used to be cited here too; it was removed with the routing change + # (backend#2722) and is no longer a reason for anything. + # + # NOT PROVEN BY READING. An under-scoped token fails at the call site, and + # this workflow's failure mode is a card the built-in "Item closed" + # automation then sets to `Cancelled` (.github#157). The next closed PR or + # issue in any repo is the real test; read the failing call before + # widening this list. + permission-contents: read + permission-issues: write + permission-pull-requests: read + permission-organization-projects: write + + - name: Determine target Status + id: target + env: + EVENT_NAME: ${{ github.event_name }} + PR_MERGED: ${{ github.event.pull_request.merged }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + ISSUE_REASON: ${{ github.event.issue.state_reason }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + # ORG + REPO_FULL are read by the project lookups, the .kanban.yml + # override mapper and the D8 parked-card comment. Keep them exported: + # without them `set -u` aborts the script before any status= output is + # written, leaving the built-in "Item closed" project workflow to set + # Status=Cancelled. (They were originally added for the closing-PR-base + # lookup, which backend#2722 removed; the other consumers remain.) + ORG: ${{ 'tracebloc' }} + REPO_FULL: ${{ github.repository }} + # The PAT rather than the caller's GITHUB_TOKEN: this step's project reads + # and the override mapper are cross-repo. (This comment used to explain a + # closer lookup that ran tokenless and therefore never worked -- .github#126. + # backend#2722 removed the lookup entirely, so that history now describes no + # code and is dropped rather than left to mislead.) + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + set -euo pipefail + + # STATUS="" means "make no change": the update step below is guarded on + # a non-empty status and skips entirely. Every branch here either sets + # a real column or deliberately leaves STATUS empty. + STATUS="" + + SIBLING="false" + # SET WHEN THE OVERRIDE COULD NOT BE USED (saadqbal on .github#295). Drives + # the same visibility mechanism as SIBLING: a card the weekly pass must + # disposition, marked so a human can see why. + # + # UNUSABLE, NOT UNREADABLE (backend#2324). Two causes reach this holding + # state now -- a `.kanban.yml` that cannot be fetched or parsed, and one + # that reads fine but names a Status the mapping does not declare -- and + # the second sends an operator hunting a read failure that never happened. + # The mapper says which on stderr; this flag only says "we could not tell". + UNUSABLE_OVERRIDE="false" + if [ "$EVENT_NAME" = "pull_request" ]; then + if [ "$PR_MERGED" = "true" ]; then + # ONE SHARED MAPPING (backend#2243) -- and this site is why the fix + # is a shared script rather than a second `yq` read: this workflow + # never checks the caller out, so the per-repo `.kanban.yml` override + # it was silently ignoring has to be FETCHED, not read off disk. + # A REFUSED OVERRIDE MUST NOT COST THE WRITE (Bugbot, .github#295). + # `read_override` refuses on an unreadable `.kanban.yml`, and a bare + # command substitution under `set -euo pipefail` then exits this step + # before any Status is published -- so the update step's non-empty + # guard skips and the project's built-in "Item closed" automation sets + # `Cancelled`, archiving shipped-via-parent work (.github#157). That is + # the no-write path this file already documents, reached by a new + # door -- and the door opens on any repo whose override cannot be + # read, which is not a hypothetical population. + # + # `resolve` REFUSES HERE TOO NOW (backend#2324), on an override naming + # a Status the mapping does not declare. That case used to succeed and hand + # this arm a name the update step below cannot resolve to an option id + # -- so it aborted mid-write and reached the same "Item closed" branch + # anyway, having skipped the holding state entirely. One exit code, one + # policy: whatever the mapper could not answer, the card gets parked. + # + # STDERR IS KEPT. It was discarded, which cost nothing while the only + # refusal was "could not fetch" -- the flag said that much. The + # unknown-Status refusal names the branch, the bad value and the + # accepted vocabulary, and that message IS the fix for the operator. + if ! STATUS=$(python3 .kanban-map/scripts/branch_status_map.py \ + "$BASE_REF" "$REPO_FULL" "$BASE_REF" \ + | jq -r '.status'); then + # THE HOLDING STATE, NOT THE DEFAULT MAPPING (saadqbal on + # .github#295, correcting my own first fix). Writing the default + # would be "silently apply the mapping we could not confirm" -- it + # claims a promotion happened on a read that failed. The holding + # state claims nothing: it is an explicit WE COULD NOT TELL, exactly + # what the sibling arm below writes for its own reason. + # + # And it must be a WRITE rather than an exit, because the built-in + # "Item closed" automation acts on the close INDEPENDENTLY of this + # workflow. A loud red run does not protect the card; the automation + # still wins the race and sets `Cancelled`. So "refuse rather than + # guess" has to be expressed AS A WRITE here -- refusing by doing + # nothing delegates the decision to something that decides wrongly. + echo "::warning::the .kanban.yml override for $REPO_FULL could not be used (see the error above: unreadable, or it names a Status the mapping does not declare), so the Status for $BASE_REF is UNKNOWN. Writing the non-terminal holding state and labelling the card for the weekly pass -- publishing nothing would let the built-in Item-closed automation set Cancelled (.github#157)." + STATUS="On dev" + UNUSABLE_OVERRIDE="true" + fi + # KEYED ON WHETHER THE MAPPER ANSWERED, not on a list of branch names + # (Bugbot, .github#295). A `case` over the four stock branches + # overwrote ANY other result with the floor below -- so a + # `.kanban.yml` key for, say, `release/*` was computed and then + # discarded on the same run, including the `rfcs` override this + # change exists to unblock. The last branch-name list in this + # workflow is gone with it. + if [ -z "$STATUS" ]; then + # A PR merged into a SIBLING feature branch deploys nothing by + # itself — its content travels onward inside the parent PR + # (this fallthrough silently stranded 6 such cards; + # backend#1437 mechanism 1). The sibling-merge label below + # makes the condition VISIBLE for the weekly board pass. The + # Status write is deliberately KEPT: skipping it lets the + # project's built-in "Item closed" automation set closed items + # to Cancelled, archiving shipped-via-parent work as abandoned + # (Bugbot, .github#157). On dev + label is the non-terminal + # holding state the weekly pass dispositions. + STATUS="On dev" + SIBLING="true" + fi + else + STATUS="Cancelled" + fi + elif [ "$EVENT_NAME" = "issues" ]; then + if [ "$ISSUE_REASON" = "completed" ]; then + # A COMPLETED ISSUE IS TERMINAL. It goes to Done, and nothing about how it + # was closed changes that (backend#2722). + # + # This used to mirror the closing PR's Status, so a PR-closed issue landed + # in `On dev` / `FR on staging` / `Prod`. That contradicted the board model + # -- deploy state is a property of a PR; an issue is either finished or it + # is not (RFC-BACKEND-1405 D8) -- and it cost real accuracy: on 2026-08-27 + # one session cleared 117 closed issues out of deploy columns by hand + # (18 + 55 from `FR on staging`, 44 from `On dev`). Two of those passes + # were functional-review batches, so the next prod payload would have read + # 73 items larger than the work it actually contained. + # + # WHY THIS RETIRES backend#1600 RATHER THAN FIGHTING IT. #1600 found that + # issues parked here by the old mirroring NEVER advanced when the code + # shipped, and drifted permanently (2026-08-06: all 20 drifted cards were + # closed issues, 0 PRs). It fixed the stranding by teaching + # advance-deploy-env to march them onward. It was right that a closed issue + # must not be stranded, and wrong about where to put it: `Done` is terminal, + # so there is nothing left to drift, and kanban-archive sweeps it off the + # board daily instead of it being carried by every hop. The matching + # closing-issue block in advance-deploy-env.yml is removed in the same + # change -- left in place it drags these cards straight back into the deploy + # columns, and a half-fix here would look fixed while doing nothing. + # + # THE CLOSER LOOKUP GOES WITH IT, which is a second win rather than + # collateral. It existed only to tell a hand-close from a PR-close so the + # two could route differently; with one destination there is nothing to + # tell apart. It was also the source of two fail-wrong defects -- a + # transient GraphQL error reading as a hand-close (.github#126), and five + # distinct situations collapsing into one "NONE" token (.github#127) -- and + # a call that is never made cannot fail. Verified before removing: + # CLOSER_TYPE and CLOSING_PR_BASE were read nowhere outside this branch. + # + # SAFE AGAINST THE FR GATE, checked rather than assumed: fr-gate ranks + # `Done` 11 against `On dev` 6, so a terminal card satisfies both the + # staging and the prod gate instead of blocking them. Its own comment + # records that being learned the hard way, when strict equality meant "a + # single Done card blocked every prod" promotion. + STATUS="Done" + else + # not_planned, or closed with no state_reason at all → abandoned. + STATUS="Cancelled" + fi + else + echo "Unexpected event '$EVENT_NAME' — skipping" + fi + + # Grouped: three consecutive individual redirects trip SC2129, and + # actionlint is a required check here. + { + echo "status=$STATUS" + echo "sibling=$SIBLING" + echo "unusable_override=$UNUSABLE_OVERRIDE" + } >> "$GITHUB_OUTPUT" + echo "Routing decision: status=${STATUS:-(unchanged)} sibling=$SIBLING unusable-override=$UNUSABLE_OVERRIDE" + + # Visibility for backend#1437 mechanism 1. Runs AFTER the Status update + # so a label failure can never suppress the column write - with the label + # first, its hard-fail skipped the update via the implicit success() + # condition and left the card wherever the built-in automation put it + # (Bugbot, .github#157). PAT rather than the caller's GITHUB_TOKEN: + # caller permission sets vary per repo. + - name: Update Status on the kanban + if: steps.target.outputs.status != '' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ORG: ${{ 'tracebloc' }} + PROJECT_NUMBER: ${{ 2 }} + STATUS_NAME: ${{ steps.target.outputs.status }} + NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + IS_PR: ${{ github.event_name == 'pull_request' }} + REPO_FULL: ${{ github.repository }} + run: | + set -euo pipefail + REPO_NAME="${REPO_FULL#*/}" + + # Look up project ID + Status field/option IDs + # 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") + + PROJECT_ID=$(echo "$PROJ" | jq -r '.data.organization.projectV2.id') + 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 "$STATUS_OPT" ] || [ "$STATUS_OPT" = "null" ]; then + echo "Could not resolve Status option '$STATUS_NAME' — aborting" + exit 1 + fi + + # Find item ID for this PR or issue + if [ "$IS_PR" = "true" ]; then + # shellcheck disable=SC2016 # $num is a GraphQL variable - must not expand in shell + FIELD_QUERY='pullRequest(number: $num) { projectItems(first: 10) { nodes { id project { number } fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } } } }' + else + # shellcheck disable=SC2016 # $num is a GraphQL variable - must not expand in shell + FIELD_QUERY='issue(number: $num) { projectItems(first: 10) { nodes { id project { number } fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name } } } } }' + fi + + # Retry briefly to let auto-add fire if needed. + # + # A FAILED LOOKUP IS NOT AN ABSENT CARD -- the same class as + # advance-deploy-env's per-PR read (Bugbot, org-config#3). This used to + # be `... 2>/dev/null) || RESP='{}'`, so five rate-limited reads in a + # row read as "not on project after 5 retries", exit 0, and the card + # kept a Status the merge had just made false while the run stayed + # green. A failed attempt is now retried like a miss, but REMEMBERED: + # if the LAST attempt failed, the step fails below instead of skipping. + LOOKUP_ERRF=$(mktemp) + LOOKUP_ERR="" + for i in 1 2 3 4 5; do + LOOKUP_ERR="" + if ! RESP=$(gh api graphql -f query=" + query(\$org: String!, \$repo: String!, \$num: Int!) { + repository(owner: \$org, name: \$repo) { + $FIELD_QUERY + } + }" -F org="$ORG" -F repo="$REPO_NAME" -F num="$NUMBER" 2>"$LOOKUP_ERRF"); then + LOOKUP_ERR=$(tr '\n' ' ' <"$LOOKUP_ERRF") + LOOKUP_ERR="${LOOKUP_ERR:-gh exited non-zero with no message}" + echo "::warning::#$NUMBER project-item lookup failed (attempt $i/5): $LOOKUP_ERR" + ITEM_ID=""; CURRENT_COL="" + sleep 5 + continue + fi + + ITEM_ID=$(echo "$RESP" | jq -r --arg n "$PROJECT_NUMBER" ' + ([.. | objects | select(has("projectItems"))] | first).projectItems.nodes[]? + | select(.project.number == ($n | tonumber)) | .id' | head -1) + CURRENT_COL=$(echo "$RESP" | jq -r --arg n "$PROJECT_NUMBER" ' + ([.. | objects | select(has("projectItems"))] | first).projectItems.nodes[]? + | select(.project.number == ($n | tonumber)) | .fieldValueByName.name // ""' | head -1) + if [ -n "$ITEM_ID" ] && [ "$ITEM_ID" != "null" ]; then break; fi + echo "Item not yet on project, retry $i/5..." + sleep 5 + done + + if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then + if [ -n "$LOOKUP_ERR" ]; then + echo "::error::#$NUMBER project-item lookup FAILED on the last attempt -- its board state is UNKNOWN, not absent, and Status=$STATUS_NAME was NOT written: $LOOKUP_ERR" + exit 1 + fi + echo "Item #$NUMBER not on project after 5 retries — skipping." + exit 0 + fi + + # Done means "completed, nothing deployed" (D8), so it must never overwrite a + # column that records a deployment. kanban-reconcile.yml refuses this and the + # router did not, which is an asymmetry with a real path: reopen, then + # hand-close an issue sitting in On dev / FR on staging / Ready for prod, and + # the deploy state is gone -- after which kanban-archive.yml, which now + # archives Done, hides the card entirely (Bugbot, .github#126). + # + # Only Done is guarded. A PR-derived Status is a deploy fact and may advance a + # card normally. + # WHICH COLUMNS ARE DEPLOY STATES, ASKED OF THE BOARD (backend#1846). + # + # This was a hand-maintained list of six names, duplicated in + # kanban-reconcile.yml -- and it had already rotted once: it carried the + # pre-rename "Staging (human review)" and not the board's actual + # "Staging (agent review)", so a card hand-closed in that column lost its + # deploy state and kanban-archive.yml then hid it (.github#237). Fixing + # that instance left the CLASS: the next rename or inserted column + # reopens it, silently, in two files. + # + # The board already answers this. `$PROJ` carries the Status options in + # PIPELINE ORDER, so a deploy state is any column at or after "On dev" + # and at or before "Prod". An inserted column -- which is exactly how + # "Staging (agent review)" arrived -- is classified correctly with no + # edit here, and a renamed intermediate column keeps working because its + # POSITION is what matters, not its name. + # + # Two anchors instead of six names, and both are written by this same + # workflow, so .github#247's checker already asserts they exist. + col_index() { + echo "$PROJ" | jq -r --arg s "$1" \ + '[.data.organization.projectV2.fields.nodes[] + | select(.name=="Status") | .options[].name] | index($s) // -1' + } + # selftest:classify-start (byte-identical in kanban-reconcile.yml) + # Is this column a DEPLOY STATE? Answered from the board's own ORDER -- + # any column at or after "On dev" and at or before "Prod" -- so a column + # INSERTED between them is classified correctly with no edit here + # (backend#1846). Only `col_index` differs between the two workflows, + # because only their inputs differ; this decision must not. + # + # yes a deploy state + # no not one -- including NO COLUMN AT ALL, which is evidence + # nothing deployed rather than a column we cannot place + # unknown a column the board does not report + # noboard the board's anchors are MISSING OR INVERTED, so nothing can + # be placed. Inverted counts as much as missing: this helper's + # whole thesis is that POSITION decides, which makes option + # ORDER load-bearing. Drag "Prod" above "On dev" and the range + # _d.._p is unsatisfiable, so EVERY column -- "Prod" itself + # included -- comes back `no`, the router writes Done over + # shipped state and reconcile asserts it. A reordered board is + # UNREADABLE, not evidence that nothing deployed, so it fails + # closed exactly like a missing anchor (backend#1994). + classify_column() { + case "${1:-}" in + ""|"No status") echo no; return ;; + esac + _c=$(col_index "$1"); _d=$(col_index "On dev"); _p=$(col_index "Prod") + if [ "$_d" -lt 0 ] || [ "$_p" -lt 0 ] || [ "$_d" -gt "$_p" ]; then echo noboard; return; fi + if [ "$_c" -lt 0 ]; then echo unknown; return; fi + if [ "$_c" -ge "$_d" ] && [ "$_c" -le "$_p" ]; then echo yes; else echo no; fi + } + # selftest:classify-end + if [ "$STATUS_NAME" = "Done" ]; then + # UNKNOWN MUST NOT FALL OPEN -- the other half of #1846. A column this + # workflow cannot place used to sail past the `case` and let Done erase + # a deploy state. Refusing costs a card sitting where it is; falling + # open erases the fact that it shipped. + # selftest:policy-start + _ds=$(classify_column "${CURRENT_COL:-}") + case "$_ds" in + noboard) + echo "::error::the board's 'On dev'/'Prod' anchors are missing or out of order, so a deploy state cannot be recognised - refusing to set Done on #$NUMBER" + exit 1 ;; + unknown) + echo "::notice::#$NUMBER sits in '${CURRENT_COL:-}', which this board does not report as a column - NOT setting Done rather than guessing" + exit 0 ;; + yes) _protect=yes ;; + *) _protect=no ;; + esac + # selftest:policy-end + if [ "$_protect" = "yes" ]; then + echo "::notice::#$NUMBER hand-closed but sits in '$CURRENT_COL', a deploy state - NOT setting Done (D8: follow the PR's stage)" + # AND SAY SO WHERE SOMEONE WILL SEE IT. Refusing is right, but it + # parks the card with no way to self-heal, and a run-log notice is + # invisible by the time anyone looks at the board. The two real + # cases needed OPPOSITE answers -- backend#1493 had shipped via + # cli#452 and belonged in Prod; data-ingestors#488 was reverted and + # belonged in Done -- so no default is correct and only the person + # closing it knows which. + CLOSE_NOTE="Closed while the board still shows \`$CURRENT_COL\`, which records a deployment." + CLOSE_NOTE="$CLOSE_NOTE The automation will not overwrite a deploy state with \`Done\` (RFC-BACKEND-1405 D8)," + CLOSE_NOTE="$CLOSE_NOTE so this card stays where it is until someone says which happened:" + CLOSE_NOTE="$CLOSE_NOTE **it shipped** - move the card to the column it reached (\`Prod\` if it is in production);" + CLOSE_NOTE="$CLOSE_NOTE **nothing was deployed** (reverted, abandoned, superseded) - clear the deploy state, then \`Done\`." + CLOSE_NOTE="$CLOSE_NOTE Both cases are real and they need opposite answers, which is why this is not decided automatically." + gh issue comment "$NUMBER" --repo "$REPO_FULL" --body "$CLOSE_NOTE" >/dev/null 2>&1 \ + || echo "::warning::could not comment on #$NUMBER - it is parked in '$CURRENT_COL' with no note on the issue" + exit 0 + fi + fi + + # shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal + 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="$STATUS_FIELD" -f o="$STATUS_OPT" > /dev/null + + echo "→ #$NUMBER → Status=$STATUS_NAME" + - name: Label a card whose override could not be used + if: steps.target.outputs.unusable_override == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO_FULL: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }} + run: | + set -euo pipefail + # Same write rule as the sibling label below: a swallowed failure means the + # card silently loses the only marker saying its Status is a placeholder. + # `override-unusable`, not `override-unreadable` (backend#2324): the label + # is the operator's entry point, and a card can now reach this state with a + # `.kanban.yml` that read perfectly and named a column that does not exist. + # + # NOTHING TO MIGRATE, AND THE REASON IS MEASURED RATHER THAN INFERRED + # (backend#2801). This used to rest on a claim that the fleet had no + # adopters of the override at all, which stopped being true. The + # CONCLUSION survives on its own evidence: `override-unreadable` is + # carried by ZERO issues or PRs org-wide (measured 2026-08-28), so there + # is still nothing to migrate. Do not reintroduce a clause about how many + # repos adopt the override -- it is a fact about the org that no comment + # can keep true, it was load-bearing for a different conclusion in + # scripts/branch_status_map.py, and that file's selftest now refuses it + # here too. + # + # KEEP THE DESCRIPTION UNDER 100 CHARACTERS (Bugbot, #302). That is the + # label API's cap; over it the create 422s, `set -euo pipefail` aborts the + # step, and the parked card never gets the marker this holding state exists + # to leave. My first wording was 133. The selftest measures it now. + if ! gh api "repos/$REPO_FULL/labels/override-unusable" >/dev/null 2>&1; then + gh api "repos/$REPO_FULL/labels" -f name=override-unusable -f color=d4c5f9 -f description="Holding state: .kanban.yml unreadable, or names an undeclared Status (backend#2324)" >/dev/null + fi + gh api -X POST "repos/$REPO_FULL/issues/$NUMBER/labels" -f "labels[]=override-unusable" >/dev/null + + - name: Label sibling-merged PR + if: steps.target.outputs.sibling == 'true' + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO_FULL: ${{ github.repository }} + NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + # Write failures FAIL this step (the PROJECTS_KANBAN_TOKEN write + # rule): a swallowed error means the card silently loses its only + # visibility marker (Bugbot, .github#157). Only the ensure-label + # probe may miss quietly - a 404 there just means create it. + if ! gh api "repos/$REPO_FULL/labels/sibling-merge" >/dev/null 2>&1; then + gh api "repos/$REPO_FULL/labels" -f name=sibling-merge -f color=bfdadc -f description="Merged into a sibling feature branch; content ships via the parent PR (backend#1437)" >/dev/null + fi + gh api -X POST "repos/$REPO_FULL/issues/$NUMBER/labels" -f "labels[]=sibling-merge" >/dev/null From ab0605ee252abcbed9a1fdb5f90b388a3a45086a Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:11 +0200 Subject: [PATCH 08/12] chore(onboard): add .github/workflows/set-pr-status.yml (content profile) --- .github/workflows/set-pr-status.yml | 451 ++++++++++++++++++++++++++++ 1 file changed, 451 insertions(+) create mode 100644 .github/workflows/set-pr-status.yml diff --git a/.github/workflows/set-pr-status.yml b/.github/workflows/set-pr-status.yml new file mode 100644 index 0000000..444b171 --- /dev/null +++ b/.github/workflows/set-pr-status.yml @@ -0,0 +1,451 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/set-pr-status.yml` +# (blob 953b8bb9bbc8675586987c39bbbd853da18c508c) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/set-pr-status-caller.yml` (blob cc4bdf86add1f96e79ee3a1b3c7ae426e97b003f), 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 tracebloc/org-config. 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 +# `set-status / ` prefix so the check contexts branch protection requires do +# not change. Scripts are still fetched from org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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: Set PR card Status on project + +# Reusable workflow. Called from each active repo on PR open / reopen / +# draft-toggle / title-or-body edit (`edited`, added under tracebloc/backend#2556 +# so the `closing-ref` job re-runs when the fields it reads change). +# Moves the PR's card to the right Status: +# - draft PR → In progress +# - non-draft → Code review +# +# TWO JOBS, and the second one is here on purpose (tracebloc/backend#2364). +# +# set-status writes the card's Status (below) +# closing-ref checks that a PR whose TITLE names a ticket actually LINKS it +# +# WHY closing-ref LIVES HERE rather than in a new reusable or in code-quality.yml. +# Three constraints, and this file is the only host that satisfies all three: +# +# 1. It needs a token that can read the PR *and* an issue in ANOTHER repo -- +# almost every house ticket is in `tracebloc/backend`, and nearly every repo +# is private. This file already mints an ORG-SCOPED installation token for +# the `tracebloc-release-train` App (measured 2026-08-23: `issues: write`, +# `pull_requests: write`, `repository_selection: all`), and every one of its +# 19 callers already passes `secrets: inherit`. `code-quality.yml` cannot: +# it is deliberately secretless and declares `contents: read` only, and a +# called workflow may not hold more than its caller grants -- adding +# `pull-requests: read` there would fail all 16 callers at startup with no +# jobs, before any of them could be updated. +# 2. It must run on the SAME event that puts a card in `Code review`, because +# the convention it enforces is what makes that card move again later. That +# event is this file's trigger set by construction. +# 3. A NEW reusable would need a `repo-inventory.yml` row for all 19 repos, and +# that file is guarded by `conformance-gate.yml` -- a required check that +# every other merge invalidates. A job in an existing reusable needs no +# inventory row at all: the inventory tracks CALLERS, one row per reusable. +# +# NOT A REQUIRED STATUS CHECK ANYWHERE, measured before landing: no repo's +# protection or rulesets name `set-status`, and `closing-ref` is new. So a +# finding here is loud and blocks nothing -- which is the point while the fleet +# is still non-compliant (measured 2026-08-23: of the last 25 merged PRs in each +# of 19 repos, 44 pass, 192 would report a finding, 239 name no ticket at all). +# Arming it as a required context is a separate, later decision that belongs +# after that backlog, not in the change that introduces the check. + +on: + pull_request: + # `edited` is load-bearing, not decoration (tracebloc/backend#2556). The + # `closing-ref` job decides its verdict from the PR TITLE and the PR BODY, and + # `edited` is the ONLY event GitHub fires when either changes. Without it the + # two fields the gate reads are the two fields that can change without + # re-running it, which is a bypass rather than a gap: open a PR titled + # `chore: tidy up`, the gate records NOTHING_NAMED and goes green, then + # retitle it to `fix(1234): …` with nothing linked -- no event fires, the + # green stands, and the PR merges having defeated the check. + # + # It also makes the remediation usable at all. Retitling or editing a body to + # SATISFY the gate did not re-run it either, so a red persisted on a PR that + # now complied: 20 sync PRs had to be cleared with 20 manual `gh run rerun` + # calls (backend#2555), because the only in-band re-trigger was closing and + # reopening someone else's PR. + # + # Same one-word fix, same reason, as `fr-gate-caller.yml` (backend#1945): a + # gate whose verdict depends on a mutable field must re-run when that field + # mutates. + types: [opened, reopened, ready_for_review, converted_to_draft, edited] + +# ONE RUN PER PR WINS, AND IT IS THE NEWEST (backend#2767). +# +# Every trigger this file answers can fire twice in quick succession -- a +# title fixed straight after a body edit, a ready_for_review immediately +# after an edit -- and `edited` made that ordinary rather than rare: the +# 19-PR sweep for backend#2756 armed `edited` on all 20 callers, so what +# was latent on one repo is now live fleet-wide. +# +# Without a group the runs race and the LOSER writes last: an older +# evaluation of the title/body overwrites a newer one, and the card (or the +# closing-ref verdict) settles on stale input. Nothing errors; the board is +# simply wrong, which is the failure mode this whole workflow exists to +# prevent one level down. +# +# `github.ref` is `refs/pull//merge` on these events, so the group is +# already per-PR; `github.workflow` is the CALLER's workflow name, which +# keeps two different callers in one repo apart. Same shape as +# `rfcs`'s code-quality-caller.yml, deliberately -- one house pattern. +# +# QUEUE, DO NOT CANCEL. The previous revision said "CANCELLING IS SAFE HERE, +# and that is measured rather than assumed ... GitHub surfaces the newest per +# context ... so a cancelled older run blocks nothing." Half of that holds and +# the half that does not is the half that matters. +# +# `gh pr checks` does dedupe to the newest per context, which is where the +# claim came from. `statusCheckRollup.state` does NOT: it aggregates +# WORST-OF across every check run on the commit, so one CANCELLED run sits +# beside three SUCCESS runs of the same name and the rollup reports FAILURE. +# That is what the PR's red X renders from, and what every tool reading the +# rollup sees -- including our own `ship.py` scan. +# +# Measured on `.github#387` (2026-08-30, four runs at 07:39 -- the train +# creates the PR, then edits the body, and `edited` cancelled the in-flight +# `opened` run): +# +# mergeStateStatus=BLOCKED rollup=FAILURE +# EVERY non-SUCCESS context: +# set-status / set-status = CANCELLED +# set-status / closing-ref = CANCELLED +# quality / format = SKIPPED +# +# Nothing else was non-green. "Blocks nothing" was right about MERGING -- these +# are not required contexts -- and wrong about what a human sees. A red X that +# is not a real failure is not cosmetic: on 2026-08-28 two reviewers withheld +# approval over exactly this shape of stale red (@saqlainsyed007 on .github#369, +# @aptracebloc on client#890), and both were reading the evidence correctly. +# +# Keeping the GROUP still solves what the group was for. With +# `cancel-in-progress: false` a second run goes PENDING until the first +# finishes rather than running beside it, so the newer evaluation still writes +# last and the card cannot settle on stale input. +# +# THAT IS ORDERING WITHIN THE GROUP, AND ONLY THAT (@saadqbal on #388). An +# earlier draft said "nothing is lost", which is too strong. Cancelling also +# TRUNCATED a superseded run; queuing guarantees it completes. Both the +# `if: state == 'open'` condition and the draft read come off the FROZEN event +# payload, so an `edited` run that lands after the closure router has already +# moved a merged card can still drag it back to `Code review`. Cancelling +# narrowed that window by accident, not by design, and it paid for it with a red +# X on every superseded run. Queuing is the better trade, not a free one. +# +# RESIDUAL, stated rather than glossed: GitHub cancels a previously PENDING run +# when a third event supersedes it, so a burst of 3+ rapid edits can still +# leave one CANCELLED entry. The create-then-edit the train actually does is +# two events, which this removes entirely. +concurrency: + group: set-pr-status-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + # ONLY WHILE THE PR IS OPEN (tracebloc/backend#2556). This condition is a + # DIRECT CONSEQUENCE of adding `edited` to the callers, and it is not optional. + # + # Before `edited`, every trigger this workflow had -- opened, reopened, + # ready_for_review, converted_to_draft -- was reachable only on an OPEN PR, so + # an unconditional write to `Code review` was always correct. `edited` is not: + # GitHub fires it when a MERGED PR's title or body is edited, and this job + # writes Status unconditionally. Editing a typo in a shipped PR's description + # would therefore drag its card from `Prod` back to `Code review` -- a + # DEMOTION, in the one file in the fleet whose board writes are not already + # monotonic (`advance-deploy-env.yml` compares pipeline rank; this job does + # not, because until now it could not need to). + # + # Guarded by state rather than by event name on purpose: `if: github.event.action + # != 'edited'` would have kept the demotion out but also disabled the board write + # for a DRAFT toggled by an edit, and it states the exception instead of the rule. + # The rule is that this job describes an open PR's review state. + set-status: + name: set-status / set-status + if: ${{ github.event.pull_request.state == 'open' }} + runs-on: ubuntu-latest + steps: + - name: Determine target Status + id: target + run: | + if [ "${{ github.event.pull_request.draft }}" = "true" ]; then + echo "status_name=In progress" >> "$GITHUB_OUTPUT" + else + echo "status_name=Code review" >> "$GITHUB_OUTPUT" + fi + + # Board writes authenticate as the tracebloc-release-train App, not a human's + # PAT (backend#2036). The App's installation budget is separate from any + # person's interactive `gh` usage and does not vanish when someone is + # offboarded -- `PROJECTS_KANBAN_TOKEN` was ONE user PAT shared by every + # caller in the fleet, both crons, and that person's own shell, and it was + # measured exhausted in practice (client-runtime run 31776053792, exit 1). + # + # `owner:` makes this an ORG-scoped installation token. Without it the token + # is scoped to the calling repository, which cannot write an org ProjectV2. + # + # NO FALLBACK TO THE PAT, deliberately. A fallback would make a broken App + # path look like a working migration, which is the exact failure class this + # work exists to remove. If the mint fails, this job fails. + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} + private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + # SCOPED TO WHAT THIS JOB ACTUALLY DOES (backend#2157). Without these + # it minted the App's ENTIRE permission set -- contents:write, + # issues:write, administration:read and the rest -- across every repo + # the installation covers, on every PR event in the fleet. Of the four + # mints backend#2157 left, this one has the largest exposure purely + # because of how often it runs. + # + # DERIVED FROM THE CALLS BELOW rather than copied from a template: + # organization.projectV2 { fields } -> projects, read + # repository.pullRequest.projectItems -> pull-requests, read + # updateProjectV2ItemFieldValue -> projects, WRITE + # + # NO `issues` SCOPE: this job is PR-only (`github.event.pull_request`). + # The second mint in this file already carries `permission-issues: + # read` because the closing-ref job reads the linked ISSUE -- the two + # differ because their calls differ, not by oversight. + # + # A PASSING MINT IS NOT THE PROOF. `release-train/add-to-kanban.yml` + # records why: "a mint can succeed with a scope the board write then + # needs and lacks". The evidence is a card that actually moves. + permission-pull-requests: read + permission-organization-projects: write + + - name: Update PR Status on project + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + ORG: ${{ 'tracebloc' }} + PROJECT_NUMBER: ${{ 2 }} + STATUS_NAME: ${{ steps.target.outputs.status_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO_FULL: ${{ github.repository }} + run: | + set -euo pipefail + REPO_NAME="${REPO_FULL#*/}" + + # 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") + + PROJECT_ID=$(echo "$PROJ" | jq -r '.data.organization.projectV2.id') + 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') + + # Wait briefly for the kanban auto-add workflow to register the PR on the project + for i in 1 2 3 4 5; do + # shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal + ITEM_ID=$(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 project { number } } } + } + } + }' -F org="$ORG" -F repo="$REPO_NAME" -F num="$PR_NUMBER" \ + | jq -r --arg n "$PROJECT_NUMBER" '.data.repository.pullRequest.projectItems.nodes[]? + | select(.project.number == ($n | tonumber)) | .id' | head -1) + if [ -n "$ITEM_ID" ] && [ "$ITEM_ID" != "null" ]; then break; fi + echo "PR not yet on project, waiting ($i/5)…" + sleep 5 + done + + # FAIL CLOSED (backend#2037). This used to log the miss and `exit 0`, so a + # PR that lost the add-to-kanban race stayed at No Status behind a GREEN + # check -- nothing surfaced it, and the board's "no card sits at No Status" + # invariant was broken by the workflow that reports it upheld. backend#1950 + # lost that race by four seconds and sat unnoticed. + # + # THE MEASUREMENT THAT BUDGET WAS HELD OPEN FOR HAS ARRIVED (backend#2731). + # The previous note kept the retry budget at 5 x 5s on purpose -- "widening + # it would hide how often the race is actually lost, and the point of + # failing closed is to find that out." The answer came back on 2026-08-27: + # `.github#361` and `#362` both lost it within seconds, and on `#362` the + # board ended in the exact state this job would have written. So the job's + # PURPOSE was achieved and only its VERDICT was wrong -- the worst shape a + # check can fail in, because nothing is broken and the habit it teaches is + # to merge past red. + # + # So END the race rather than widen the window. A bigger number would still + # be a guess, and a longer wait is still a wait on a sibling workflow that + # may simply never have run. This job already holds a token with + # `organization-projects: write`, and `addProjectV2ItemById` is IDEMPOTENT + # -- adding an item already on the board returns the existing item id -- so + # there is nothing here that add-to-kanban would duplicate. That workflow + # does exactly one thing (an `actions/add-to-project` step and nothing + # else), which is what makes taking it over in the missing case safe. + # + # STILL FAIL-CLOSED. "The card is missing" stays a real finding when it is + # real: if the PR cannot be resolved to a node, or the add does not come + # back with an item id, this goes RED exactly as before. What changed is + # only that "not yet" no longer reports as "missing". + if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then + echo "card absent after 5 retries - adding it here rather than waiting on add-to-kanban" + # shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal + PR_NODE_ID=$(gh api graphql -f query=' + query($org: String!, $repo: String!, $num: Int!) { + repository(owner: $org, name: $repo) { pullRequest(number: $num) { id } } + }' -F org="$ORG" -F repo="$REPO_NAME" -F num="$PR_NUMBER" \ + | jq -r '.data.repository.pullRequest.id // empty') + if [ -z "$PR_NODE_ID" ]; then + echo "::error::PR #$PR_NUMBER could not be resolved to a node id, so its card" \ + "cannot be added. Refusing to report a status nobody wrote." + exit 1 + fi + # shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal + ITEM_ID=$(gh api graphql -f query=' + mutation($p: ID!, $c: ID!) { + addProjectV2ItemById(input: {projectId: $p, contentId: $c}) { item { id } } + }' -F p="$PROJECT_ID" -F c="$PR_NODE_ID" \ + | jq -r '.data.addProjectV2ItemById.item.id // empty') + fi + + if [ -z "$ITEM_ID" ] || [ "$ITEM_ID" = "null" ]; then + echo "::error::PR #$PR_NUMBER is not on project $PROJECT_NUMBER and could not be" \ + "added to it. The board write cannot proceed; this is not a race." + exit 1 + fi + + # Pass the option ID with -f (raw string), NOT -F: ProjectV2 option IDs can + # be all-numeric, and -F coerces all-digit values to an integer, which the + # $o: String! variable rejects. -f forces a string. (Some option IDs contain + # letters today, but don't rely on that — IDs regenerate if recreated.) + # shellcheck disable=SC2016 # the $names here are GraphQL variables, not shell - keep literal + 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="$STATUS_FIELD" -f o="$STATUS_OPT" > /dev/null + + echo "→ PR #$PR_NUMBER → Status=$STATUS_NAME" + + # ------------------------------------------------------------------------ + # A PR whose TITLE names a ticket must LINK it (tracebloc/backend#2364). + # + # Measured 2026-08-23: `closingIssuesReferences` was 0 on 7 of 7 epic-relevant + # merged PRs. The house convention puts the ticket in the TITLE; GitHub creates + # a closing link ONLY from a keyword in the BODY. So the title reference is + # inert, `kanban-closure-router.yml` finds no linked issue, and correctly does + # nothing -- every kanban workflow green, every card unmoved. The board has + # been wrong for weeks behind a green pipeline. + # + # This job is a SEPARATE job from `set-status` deliberately, the same reason + # code-quality.yml keeps six: a finding here must not stop the card being + # written, and a failed board write must not hide the finding. Steps inside one + # job short-circuit; jobs do not. + # + # NOTHING FROM THE PR IS INTERPOLATED INTO A `run:` BLOCK. The title is read + # from the API by the checker itself, so a title containing shell or an + # expression is data, never code. The only expressions below are the repo name + # and the PR number. + # ------------------------------------------------------------------------ + closing-ref: + name: set-status / closing-ref + # Open PRs only, for the same reason as `set-status` above but a smaller + # consequence: on a merged PR this would be advice about a merge that already + # happened, and a red X arriving on a shipped PR hours later is noise that + # teaches people to ignore the check (rule 4). The case `edited` exists to + # catch -- a retitle -- happens while the PR is open, which is exactly when + # this still runs. + if: ${{ true && github.event.pull_request.state == 'open' }} + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # The checker lives here, not in the calling repo, so the reusable fetches + # it -- same shape as code-quality.yml, blocked-gate.yml and + # bugbot-gate.yml. This repo is PRIVATE (org-config replaced the public + # `.github` as the host on 2026-09-10), so the checkout needs a read-only + # App installation token; the App id and key arrive because every caller + # of this reusable passes `secrets: inherit` (header, point 1). + - 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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 + + - name: Check out the shared checker + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tracebloc/org-config + token: ${{ steps.org-config-token.outputs.token }} + ref: ${{ 'main' }} + path: .closing-ref-tools + persist-credentials: false + + # WHY AN APP TOKEN AND NOT `github.token`. The link this reads points at an + # issue in ANOTHER repo -- `tracebloc/backend` for most of the fleet -- and + # a repo-scoped GITHUB_TOKEN cannot see it. A node the token cannot read + # comes back missing from `nodes` while `totalCount` still counts it, which + # the checker reports as "cannot tell" rather than "not linked". That is the + # right refusal, but it would be EVERY cross-repo PR's verdict, so the + # token has to be the org-scoped one. + # + # SCOPED, unlike the mint in `set-status` above (backend#2157): this job + # reads two things and needs nothing else. `set-pr-status.yml` keeps its + # `mint-scope` exemption for the unscoped board-write mint above; this mint + # does not use it. + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} + private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + permission-pull-requests: read + permission-issues: read + # NO `contents` scope, on purpose (backend#3240). The gate used to read + # `baseRepository { defaultBranchRef { name } }` over GraphQL; a Ref is + # a contents object, world-readable on a public repo and gated on a + # private one, and from 2026-09-06 06:29Z that single field made every + # private-repo run fail with "Resource not accessible by integration" + # while public repos passed in the same minute. This token is + # org-scoped (`owner:` + an all-repositories installation), so adding + # contents:read here would have widened it on every repo for a value + # the event payload already carries: it is passed in below as + # PR_BASE_DEFAULT_BRANCH instead, and the query no longer asks. + + - name: The title names a ticket, so the PR must link it + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_BASE_DEFAULT_BRANCH: ${{ github.event.pull_request.base.repo.default_branch }} + SOFT_FAIL: ${{ true && 'true' || 'false' }} + run: python3 .closing-ref-tools/scripts/closing-ref-gate.py From 132a71b43c71e10d64c555ea75bc918e2a151f16 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:12 +0200 Subject: [PATCH 09/12] chore(onboard): add .github/workflows/stale-backlog.yml (content profile) --- .github/workflows/stale-backlog.yml | 157 ++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 .github/workflows/stale-backlog.yml diff --git a/.github/workflows/stale-backlog.yml b/.github/workflows/stale-backlog.yml new file mode 100644 index 0000000..69f4c67 --- /dev/null +++ b/.github/workflows/stale-backlog.yml @@ -0,0 +1,157 @@ +# GENERATED FILE -- DO NOT EDIT BY HAND (tracebloc/backend#3591). +# +# Self-contained copy of the reusable workflow `.github/workflows/stale-backlog.yml` +# (blob bcb6d3bf15af1d8b1c422f5173d5a6c1b21860a6) folded with tracebloc/org-config's own caller of it, +# `.github/workflows/stale-backlog-caller.yml` (blob 7dcfd05395d568f7a1f68de9f339717d325c6349), 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 tracebloc/org-config. 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 +# `stale / ` prefix so the check contexts branch protection requires do +# not change. Scripts are still fetched from org-config 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. +# +# Regenerate: python3 scripts/render-self-contained.py (in tracebloc/org-config) +# 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: Close stale backlog issues + +# REUSABLE, and it had to become one before the bug could be fixed +# (backend#1979 → backend#1597 item 1). +# +# THE BUG: `actions/stale` exempts on LABELS ONLY. It has no concept of the board, +# so an item in `North Stars`, `Ready for prod` or `In progress` went stale and +# auto-closed after 8 weeks of silence exactly like a Backlog item — a strategic +# priority archived because nobody commented on it. `kanban-reconcile.yml` carries a +# "Shield North Stars from the stale sweep" step, which is the workaround admitting +# it rather than the fix. +# +# Board awareness cannot be expressed as a label, so it cannot be a config knob on +# an action — it needs a script. And a script cannot be maintained as sixteen +# byte-identical copies, which is why this was a 16-way copy that had to become a +# reusable first. Fixing the copy model was the prerequisite, not the goal. +# +# The eligibility rule is an ALLOW-LIST OF ONE: exactly `Backlog`, on a card that is +# not archived. Every other column, an archived card, an issue with no card, and a +# Status that could not be read are all skipped. A column added next year is therefore +# safe by default — a deny-list of the columns we happened to think of would make it +# eligible and nobody would find out until something was closed. +# +# ARCHIVED CARDS KEEP THEIR STATUS, so "archived" had to become part of that rule +# rather than being implied by it (Bugbot, .github#292). `kanban-archive.yml` archives +# terminal items without clearing the field, and a card archived out of `Backlog` reads +# `Backlog` forever — so the sweep matched work that had been deliberately taken off +# the board, and closed it. `projectItems` returns archived items by default +# (`includeArchived: true`), so the filter is explicit in ISSUES_Q and in `status_of`. +# +# FAIL CLOSED HERE MEANS SKIP, not act. Every other guard in this org fails closed +# by refusing to report clean; this one is destructive, so an unreadable board must +# mean "leave it alone". `strict: true` additionally fails the run so an outage is +# visible rather than merely harmless. +# +# The logic and its 30-case decision table live in tracebloc/.github +# (`scripts/stale-backlog.py`, `scripts/tests/stale-backlog-selftest.py`); callers +# stay thin so the rule has exactly one home. + +on: + schedule: + - cron: '0 0 * * 1' # Mondays 00:00 UTC + workflow_dispatch: {} + +# Only `contents: read`: the sweep's writes go through the App token minted below, +# not through GITHUB_TOKEN. Asking for `issues: write` here would exceed a minimal +# caller's grant and fail the run at startup with no jobs — the same constraint +# code-quality.yml documents. +permissions: + contents: read + +jobs: + stale: + name: stale / stale + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + # The board read needs org-project access and the writes need issues:write, + # both of which the tracebloc-release-train App holds (backend#2036). This + # workflow is App-native from the start and never had a PAT to migrate off. + # `owner:` makes the token ORG-scoped; a repo-scoped one cannot read the + # project at all, and this sweep would then skip every issue as UNKNOWN — + # silent and safe, but wrong. + # + # SCOPED FROM THE START (backend#2157), derived from what the sweep actually + # calls rather than from what the App happens to hold: + # + # issues: write `issue edit --add-label`, `issue comment`, + # `issue close` (stale-backlog.py:188-192) + # organization-projects: read ISSUES_Q reads `projectItems .. Status`; the + # sweep never writes a board field + # + # No `pull-requests` — this workflow does not touch PRs, by design + # (backend#1408: it sweeps issues only). No `contents` — the App token is used + # ONLY as `GH_TOKEN` for the sweep; the `.github` checkout below uses the + # workflow token and `persist-credentials: false`. + # + # `repositories:` NARROWS THE ISSUE WRITES to the caller's own repo, which is + # exactly the sweep's scope: `--repo "${{ github.repository }}"`. Without it a + # destructive, unattended sweep would hold issue-close rights on all 19 repos + # while only ever closing in one. + # + # ORG PROJECTS IS UNAFFECTED BY REPO SCOPING -- measured, not assumed: + # backend#2181's verification run (32255581084) wrote the org project with + # `repositories:` set. That was the load-bearing unknown and it is settled. + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} + private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + repositories: ${{ github.event.repository.name }} + permission-issues: write + permission-organization-projects: read + + # The script comes from .github, not the caller: one copy of the rule. + - 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@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.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 + + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: tracebloc/org-config + token: ${{ steps.org-config-token.outputs.token }} + ref: ${{ 'main' }} + path: .stale-tools + persist-credentials: false + + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.12' + + # The decision table runs BEFORE the sweep, in the caller's own run. A sweep + # whose eligibility rule has regressed must not run at all: the failure is + # destructive and unattended, so the tests are a gate rather than a report. + - name: Selftest the eligibility rule + run: python3 .stale-tools/scripts/tests/stale-backlog-selftest.py + + - name: Sweep + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + python3 .stale-tools/scripts/stale-backlog.py \ + --repo "${{ github.repository }}" \ + --project "${{ 2 }}" \ + ${{ false && '--dry-run' || '' }} \ + ${{ false && '--strict' || '' }} From c80184cca46885f6a62f54689af23c025a464a23 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:14 +0200 Subject: [PATCH 10/12] chore(onboard): add .github/workflows/add-to-kanban.yml (content profile) --- .github/workflows/add-to-kanban.yml | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/add-to-kanban.yml diff --git a/.github/workflows/add-to-kanban.yml b/.github/workflows/add-to-kanban.yml new file mode 100644 index 0000000..2a3a432 --- /dev/null +++ b/.github/workflows/add-to-kanban.yml @@ -0,0 +1,92 @@ +name: Add to engineer kanban + +on: + issues: + types: [opened, reopened, transferred] + pull_request: + types: [opened, reopened, ready_for_review] + +jobs: + add-to-project: + runs-on: ubuntu-latest + # NO GITHUB_TOKEN AT ALL (saadqbal, #2181). Every call in this job authenticates + # as the App, so the workflow token needs nothing -- and an empty grant is the + # only version of that claim a reader can check. Free, and it means the least- + # privilege story covers both credentials in the job rather than just the loud one. + permissions: {} + steps: + # Board writes authenticate as the tracebloc-release-train App (backend#2036), + # not a human's PAT. `owner:` yields an ORG-scoped installation token; a + # repo-scoped one cannot write the org project. No fallback to the PAT: a + # fallback would let a broken App path keep working silently. + # + # This workflow also fires on DEPENDABOT PRs, which GitHub gates on a separate + # secret scope -- both app secrets are set there too, or Dependabot PRs would + # stop reaching the board with `Input required and not supplied`. + - name: Mint an installation token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.RELEASE_TRAIN_APP_ID }} + private-key: ${{ secrets.RELEASE_TRAIN_APP_PRIVATE_KEY }} + owner: ${{ github.repository_owner }} + # SCOPED TO THIS REPO, or the two reads below land org-wide (saadqbal, + # #2181). `owner:` alone does not narrow anything -- run 32239403796 says + # so in as many words: "Input 'repositories' is not set. Creating token for + # all repositories owned by tracebloc." A token calling itself + # least-privilege while carrying issue+PR read across all 19 installed + # repos is the claim this PR exists to stop making. + # + # `organization_projects` is an ORG-level permission and is not affected by + # repo scoping, so the board write should be unchanged -- but that is an + # assumption, and it is the same class of assumption that broke the first + # attempt, so the verification run is what settles it rather than this + # comment. If it is wrong the failure is LOUD (see below), which is what + # makes trying it cheap. + repositories: ${{ github.event.repository.name }} + # Least privilege (backend#2166): without any `permission-*` the token + # carries the App's FULL installation grant. actions/add-to-project needs + # THREE scopes, not one: it must RESOLVE the triggering issue/PR node + # before it can add it to the board, so it needs read on both content + # types (this workflow fires on `issues` and `pull_request`) in addition + # to the project write. Projects-write alone leaves the node unresolvable + # -- the add fails with "Could not resolve to a node with the global id". + # + # WHAT IS ACTUALLY DEMONSTRATED, and what is not. Stated narrowly because + # two earlier versions of this paragraph each overclaimed in a different + # direction, and this text is copied verbatim into 17 repos -- a wrong + # argument here is a wrong argument 17 times, in a byte-compared file + # nobody re-derives. + # + # DEMONSTRATED: a MISSING READ scope fails loudly. Run 32239403796 on this + # branch, at commit 218f0b13 (projects-write only), errored with + # `Could not resolve to a node with the global id` and the job went RED -- + # `add-to-project` routes GraphQL errors through `setFailed`. + # + # NOT DEMONSTRATED: the case the FIRST version of this comment described -- + # the token resolving the node fine and then 403ing the BOARD WRITE. No run + # has ever produced it. So "fails loudly" is proven for the read scopes and + # is an expectation, not a measurement, for the write. + # + # AND ONE RUN THAT LOOKED LIKE EVIDENCE IS NOT (aptracebloc). The previous + # wording cited run 32237283072 as a second scope failure. It is not one: + # it ran on `develop`, whose file at that sha passes NO `permission-*` at + # all (the App's full grant), and it failed on + # `Could not resolve to a node with the global id of I_kwDONNfQt88...` -- + # a node a fully-privileged token also cannot see, i.e. an issue that no + # longer exists (this workflow fires on `issues: transferred`). Run + # 32237067262, the SAME develop sha, succeeded 2m34s earlier. A dead node, + # not a permission. + # + # The proof this is right is therefore still a LANDED CARD, not a passing + # mint: a mint can succeed with a scope the board write then needs and + # lacks, and that is the one path nothing here has exercised. + permission-issues: read + permission-pull-requests: read + permission-organization-projects: write + + - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0 + with: + project-url: https://github.com/orgs/tracebloc/projects/2 + github-token: ${{ steps.app-token.outputs.token }} + From 45f783d467f745a1d4d19043d565ebcc17856896 Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:16 +0200 Subject: [PATCH 11/12] chore(onboard): add CLAUDE.md (content profile) --- CLAUDE.md | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..3346d26 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + +Repo-specific guidance for Claude Code sessions goes here, above the +managed org block: build/test commands, architecture notes, gotchas, +and this repo's default reviewer. Rollout: tracebloc/backend#1602. + + +## tracebloc engineering standards (org-wide) + + + +### Branches & PRs + +- Branch model, **for a repo on the release train**: `develop → staging → main`. Branch off `develop`; every PR targets `develop`. Never open PRs to `staging` or `main` — promotions are the train's job. +- **For a repo not on the train, do not infer the branch model from this file — read `repo-inventory.yml`.** `release_train:` says whether the model above applies at all, and the per-branch `exempt:` anchors record which branches actually exist. This bullet used to enumerate the exceptions by name and **drifted from the inventory on every one of them**: `docs` was called `main`-only while it had been on the train since 2026-08-04 (`release_train: true`, `develop: required`, staging present), and `rfcs` was called `main`-only while it had a `develop` taking merges (measured 2026-08-22, backend#2242 / .github#306). Restating the authority is the defect; pointing at it is the fix. +- **Trap, recorded in the inventory and caught by no check:** a `develop` created on a non-train repo and left **unprotected** is invisible to the guards — that is the `develop_unprotected_non_train` anchor, and the inventory notes "a `develop` created and left UNPROTECTED is not flagged … no check was going to surface it." So creating one to satisfy the first bullet **forks the repo silently**: PRs split between the new branch and the repo's existing convention, nothing promotes between them, and the two heads diverge until someone reconciles by hand. If a repo appears to lack a `develop`, that is a fact to verify in the inventory, not a gap to fill. +- Before starting any task: `git fetch` and branch from the current tip of `develop` — never build on a stale checkout. A branch that lives more than a day gets `develop` merged back in before review. We move fast; stale starts mean silent divergence and duplicated work. +- One self-contained change per PR. A few hundred changed lines reviews well; at 1000+ split it. Refactors ship in separate PRs from behavior changes. +- Branches are short-lived (aim to merge within a day or two), single-author, and based on `develop` — no stacked PRs on top of other open PRs. +- Your branches are yours to clean up. Merged ones now delete themselves server-side, so this is about the rest: run `git reap` (from `tracebloc/org-config/scripts/git-reap`) in your checkouts now and then. It is dry-run by default and only proposes a branch when it can prove the work landed. Nobody else can do this for you — you are the only one who knows whether an *unmerged* branch of yours still matters, and `git branch --merged` will not tell you, because we squash-merge and a squashed branch is not an ancestor of `develop`. +- **"Yours" is the branch you opened the PR for, never the branch whose last commit is yours.** Pushing a review fixup onto someone else's branch makes you its tip-commit author and changes nothing about whose work it is — so a "my branches" list built from `%(authorname)`, or from the tip author in any form, aims your cleanup at other people's work. Measured: two of Shujaat's `client` branches showed up on such a list and were one confirmation step away from `--delete` (backend#2365). If you are building any list that reasons about ownership, call `tracebloc/org-config/scripts/branch_owner.py` rather than re-deriving it; a branch it cannot attribute comes back as `unattributable`, which is the answer to act on, not to fill in. +- Names and commits: `feat/ fix/ docs/ sec/ ci/ chore/` + issue number + short slug (`fix/1234-ingest-timeout`); commit subjects `type(scope): summary`, referencing the ticket (`backend#1234`). **`(scope)` is the component — `mint-scope`, `kanban` — never the ticket number.** A number in a PR title (`sec(2157): …`) is read by `closing-ref` as a reference the body must make good, in one of two forms: `Closes /#N` when this PR really finishes the ticket, or `Part of /#N` when it does not. Both satisfy the check; only `Closes` closes the ticket and moves its card, so never write it for partial work — and a bare `Closes #N` resolves against the repo you are in, which for a `tracebloc/backend` ticket links the wrong issue. Keeping the number in the title is right either way: naming the parent is traceability, not a promise to close it (backend#2616). +- When you open a PR: assign yourself and request exactly one reviewer immediately — a PR without a reviewer stalls by construction. You pick the reviewer: whoever knows the code best. There is no per-repo default, and no automation assigns one — branch protection just refuses to merge without a review. +- When you are the reviewer: first response within one business day. + +### Quality bar + +- Before every push: run the linter and the tests that cover your change. Never push a branch you believe is red — CI is the backstop, not the first run. +- Read the full diff before opening the PR. You own every line you ship, whoever — or whatever — wrote it. +- AI sessions end with evidence, not assertion: run the relevant check (tests, build, lint) and show the output. A change that could not be verified does not ship. +- Fix the class, not the instance. The bug you just fixed is a member of a class; check the rest of the class before you push. Two shapes, and aiming at only the first catches half of them: **other call sites** — grep the symbol or pattern you changed — and **other inputs to the same guard** — what else reaches this branch? If the class can't be cheaply enumerated, say so in the PR rather than leaving it implied that you covered it. +- After opening or pushing to a PR, stay on it: poll CI and Bugbot on the current head and triage every finding the same day — fix it, or reply on the thread saying why not. No silent dismissals. Unresolved threads block the merge and stall the release train's settle stage; cheap now beats expensive later. +- A finding that recurs across PRs becomes a rule: add it to `.cursor/BUGBOT.md`, and if it is grep-expressible, to code-quality's house-rules — then stop re-arguing it in comments. +- Style and naming rules live in tooling (black/ruff, eslint/prettier, house-rules), never in prose. If a rule matters, encode it; do not restate linter rules in CLAUDE.md files. +- Never commit secrets, tokens, or customer data — not in code, config, tests, issues, or commit messages. gitleaks catches secrets in **code**. Nothing scans PR titles, descriptions or commit messages: the public PII gate that did was retired on 2026-08-06 (backend#1409), so keeping customer names out of PR prose on public repos is on you, not on a check. + +### Engineer kanban + +- Every ticket on the board carries a `Status` — no card sits at "No Status". New tickets start in `Backlog`. **Bugs are the exception:** label them `work-type:bug` (the Bug template does it) and automation moves the card straight into `Ready` — defects don't wait for refinement. This holds in every repo, and the exception that used to be written here is gone rather than kept accurate by hand: the labels exist fleet-wide, and `triage-labels.yml` asserts daily that every label the templates apply and the caller fires on exists in every repo declaring that caller. A hand-written exception list drifts on every entry — it named two repos while the inventory said three — so the fix was to empty it (backend#2598). +- Picking up work: the team coordinates. `Ready` is the refined queue — bugs excepted, per the line above — and the first choice when it's stocked; pulling from `Backlog` is normal when refinement hasn't caught up — say what you're taking. +- Merging to `develop` moves the card to `On dev` automatically; there is no dev-side review. +- Functional review happens once, on staging: when it passes, comment `/fr-pass` on the PR or drag the card to `Ready for prod`. Self-signoff is allowed. +- `fr-gate` is a required check on promotions. If it blocks, the board or the work isn't ready — fix that. `skip-fr-gate` is audited, for emergencies only. + +### Releases & publishing + +- The release train is the only path to `staging`, `main`, and every package registry. Never hand-cut a `v*` tag or publish an artifact — every legal publish path is inventoried in release-train's `PUBLISH-PATHS.md`. _(The `hand-bump a version file` clause was removed on 2026-09-08: it contradicted a REQUIRED check, and the meta-rule above says an enforced rule leaves this file. `version-bump-gate / version-check` fails a PR that touches a published path while the version file still reads an already-released version — "Bump package.json in this PR. The release train reads that file and cuts the tag from it — it never bumps for you." So the bump a feature PR ships is the train's INPUT, not a bypass of it. Read literally, the old clause forbade what the gate demands: it blocked two component PRs on design-system-v2 until someone put the two side by side, and a reviewer there opened and then retracted a change-request over the same collision.)_ +- Findings on a promotion PR are fixed on the source branch (`develop`/`staging`), then the train re-prepares. Never push fixes onto a promotion PR — every push re-rolls its review. + +### Filing issues + +- Internal work — planning, epics, security findings, infrastructure, anything mentioning a customer — is filed in `backend` (the private catch-all), never in a public repo. When in doubt: `backend`. +- Public repos (`cli`, `client`, `docs`, `data-ingestors`, `model-zoo`, `start-training`, `.github`) only get issues a stranger could act on: about the public artifact itself, with no customer names, internal URLs, or internal paths. + +### AI-assisted sessions (Claude Code, etc.) + +- An AI session may open PRs and push its own branches. It never: merges a PR, closes another person's PR, deletes another person's branch, or force-pushes — each of those needs an explicit instruction from the human running it. +- If your change makes a statement in any CLAUDE.md, BUGBOT.md, or runbook false, update that file in the same PR. + From 2c15a24bc2521c2165c03fd3fc1303295dc8d51e Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 11:42:17 +0200 Subject: [PATCH 12/12] chore(onboard): add .cursor/BUGBOT.md (content profile) --- .cursor/BUGBOT.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .cursor/BUGBOT.md diff --git a/.cursor/BUGBOT.md b/.cursor/BUGBOT.md new file mode 100644 index 0000000..c49e348 --- /dev/null +++ b/.cursor/BUGBOT.md @@ -0,0 +1,7 @@ +# Bugbot guide + +What Bugbot should know before reviewing a diff in THIS repo: the stack, the +conventions that are not enforced by a linter, and the defect classes that +have recurred here. Per-repo by design -- see `.cursor/BUGBOT.md` in +tracebloc/backend for the shape. Presence is asserted by the conformance +audit (repo-inventory.yml `quality_files`); content is yours.