From d5f36e074f5db0c422edb6936146c10a3a62a0dd Mon Sep 17 00:00:00 2001 From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:54:28 +0200 Subject: [PATCH 1/4] ci(mirror): fail-closed publish guard + publisher for the public deliverable mirror (#654) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ci(mirror): fail-closed publish guard + publisher for the public deliverable mirror The public face of this repo becomes a separate, deliverable-only mirror: README/LICENSE/docs on its branch and each release with its signed assets. No Go source ever leaves. Nothing reaches the mirror except through the new Mirror publish workflow, and nothing leaves that workflow except what scripts/publish-guard.sh staged from the explicit allowlist (.publish-include) and cleared through four guards: forbidden paths and forbidden strings (.publish-forbidden), and gitleaks. Every guard fails closed; "could not tell" never publishes. scripts/publish-mirror.sh does the push: it refuses an unset mirror and a mirror equal to this repository, pushes plainly (never force), and never overwrites an existing release. The workflow runs after Release completes and on dispatch with dry-run defaulting to true; the mirror name (MIRROR_REPO) has no default. Customer identifiers for the string scan are supplied privately at publish time, not committed to this public file. build.yml's Installer job shellchecks the scripts and runs both harnesses. Co-Authored-By: Claude Fable 5.1 * ci(mirror): split the string guard into refuse and report tiers A guard that can never pass is a guard nobody arms: on the real tree the single [strings] tier refused with 26 hits (README and the two installers), all of them internal ticket identifiers and non-production hostnames whose fate is still a decision, not a defect. The scan now has two tiers in .publish-forbidden: - [strings-refuse] mailboxes (support@ spared), arn:aws:, ECR account ids, plus the private tenant needles from --extra-forbidden. A hit refuses, as before. - [strings-report] ticket/RFC identifiers and non-production hosts. Hits are counted and printed (per-needle totals, ten most-hit files) but refuse only under --strict. The list itself is refused (exit 2) on an unknown section header, a needle in both tiers, or an empty [strings-refuse]. The header regex is loose on purpose so a misspelt header is refused by name rather than read as a needle of the section before it. The workflow gains a `strict` dispatch input (default false) and honours the PUBLISH_STRICT repository variable for every run, including workflow_run, which has no inputs; flipping either arms the report tier. Tests: 34 -> 43 (42 in CI, where the real-gitleaks case skips). Report-tier hit alone -> exit 0 with count and table; the same under --strict -> exit 1 naming [strings-report (strict)]; refuse-tier hit -> exit 1 naming [strings-refuse]; duplicate needle, unknown section and empty refuse tier -> exit 2 with the offender named. The committed list is exercised needle by needle with inputs written in the test, and the real tree is now asserted clean (exit 0). Each new check was mutation-proved. Co-Authored-By: Claude Fable 5.1 * ci(mirror): run the tooling from this commit, treat the tag as data, keep prereleases off the default branch Review findings on the mirror-publish workflow, each with its test: - Untrusted checkout. The job checked out the release tag and then ran scripts/publish-guard.sh and publish-mirror.sh from it while the job went on to mint an App token. Now the one actions/checkout has no ref (the workflow's own commit), and the release tag is fetched separately into a detached worktree under RUNNER_TEMP as data — staged and scanned, never executed. Before the fetch the plan step requires the release object's tag_name to equal the run's tag and takes the expected commit from workflow_run.head_sha (a dispatch asks the API); the fetched tag must resolve to exactly that commit or the run refuses. The guard reads the allowlist and forbidden list from the tooling checkout, whatever the tag carries. - Prerelease overwrote the mirror's default branch. workflow_run always publishes, and the tree push never looked at PRERELEASE. The plan step now derives publish_tree=false for a prerelease and says why; the default branch push is gated on it; the release is still created, marked prerelease, pinned to the mirror's current default-branch head — an empty mirror is refused rather than given an RC as its first content. - Captured output hid refusals. `target` and `tree` ran through `$(...)`, so under set -e their ::error:: lines never reached the log. Both now run directly and write their results (repo=/name=, result=/sha=) through a new --output FILE option, which the workflow points at $GITHUB_OUTPUT. - The gitleaks download carries --tlsv1.2 like every other privileged fetch in this repository. scripts/tests/mirror-publish-workflow-verify.sh executes the plan, src, target and keep step bodies read out of the workflow itself (gh shimmed, the tag fetch against a real bare repo) and pins the shape — no checkout ref, the tree push gated, the release step not, no captured publisher, one pinned tag fetch — with mutations asserted to change the document before they are judged; build.yml runs it beside the other harnesses. publish-mirror-verify.sh covers --output for target and tree, including that a refusal writes nothing and annotates stdout. Each new check was mutation-proved against the real workflow and script. The release checklist notes the trust shape and the prerelease behaviour. Co-Authored-By: Claude Fable 5.1 * test(mirror): pin the fixture origin's HEAD so the tag-fetch case runs on a fresh runner The workflow harness's bare origin relied on init.defaultBranch: unset on the runner, its HEAD pointed at a `master` nobody pushed, the clone had an unborn HEAD, and `rev-parse HEAD` handed the src step the literal word HEAD as the expected commit — the case failed with "cut at HEAD" instead of proving the pinned fetch (Installer (shell) on the previous push). The bare HEAD is now set to main explicitly and the commit is read from the seed repository; reproduced locally with GIT_CONFIG_KEY_0=init.defaultBranch GIT_CONFIG_VALUE_0=master before and after. Co-Authored-By: Claude Fable 5.1 * fix(guard): strip [allow] tokens as whole words, name private needles by number only Two review findings on scripts/publish-guard.sh (kept byte-identical with the client repository's copy): - The [allow] pass removed a token with an unanchored, case-sensitive sed replace before re-testing the needle. A mailbox that merely ENDS in the public support address (devsupport@…) lost the token, the mailbox rule no longer matched, and the internal address could ship; a differently-cased public address was refused. The strip now matches the token as a whole word — not the tail of a longer local part, not the head of a longer domain, a sentence-ending dot still a boundary — and case-insensitively, as the scan itself matches. - Refuse-tier hits printed the needle, and the workflow tees that log into the run summary. The needles from --extra-forbidden are the identifiers kept out of the committed list because this repository is public, so one deliverable hit would have published them in the log. Private needles are now scanned in their own pass and named `private needle #N` in every line the guard prints or writes (stdout, the report file, grep-error text); the committed needles are still named by pattern. publish-guard-verify.sh: devsupport@ is refused, Support@Tracebloc.io. at a sentence end passes, and the tenant case asserts the redacted name and that the pattern appears neither in the output nor in publish-guard-report.txt. Each check was mutation-proved by restoring the old strip and the old label in the script and watching only its named cases redden. Co-Authored-By: Claude Fable 5.1 * fix(mirror): push the tree only for the newest stable release Any non-prerelease tag set publish_tree=true, so a Release re-run or a dispatch of an OLDER stable tag replaced the mirror's default branch with that tag's README and docs — and, with the release already mirrored, refused right after, leaving the rollback in place and paired with nothing. The plan step now asks GitHub for the source repo's newest stable release (releases/latest) whenever the tag is not a prerelease. A tag that is not that release mirrors only its GitHub release, pinned like a prerelease to the default branch's current head, and the log says why. If the newest release cannot be read the run refuses rather than guess. The keep step's name and messages cover both cases now. mirror-publish-workflow-verify.sh: the gh shim answers releases/latest from GH_LATEST_TAG / GH_LATEST_RC; new cases for an older stable tag (publish_tree=false), an unreadable newest release (refused), a prerelease not consulting it, and a plan-body mutation that disarms the comparison — the older-tag case catches it. 20 cases. Co-Authored-By: Claude Fable 5.1 * ci(mirror): refuse a non-boolean isPrerelease; keep the guard summary under errexit Two hardenings in the plan and guard steps of mirror-publish.yml: - isPrerelease from the release API must be exactly `true` or `false`. A missing or malformed value (`jq -r` prints `null`) used to fall through into the stable path and arm the tree push; it is now refused before the newest-release question is asked. - The guard step has a stable id (guard-tree) and catches the guard's exit status with `rc=0; ... | tee ... || rc=$?`. Actions runs the body under `bash -e`, so the earlier `rc=${PIPESTATUS[0]}` never ran on a refusal and the step summary stayed empty; the step still exits with the guard's own status. mirror-publish-workflow-verify.sh runs every step body under `bash -e` (as Actions does) and pins both: the null case, the refusal-to-summary case, and a mutation for each (accept any isPrerelease -> null publishes the tree; drop `|| rc=$?` -> summary empty) that the new cases catch. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .github/workflows/build.yml | 25 + .github/workflows/mirror-publish.yml | 452 +++++++++++++++++ .publish-forbidden | 79 +++ .publish-include | 16 + scripts/RELEASE_CHECKLIST.md | 20 + scripts/publish-guard.sh | 471 ++++++++++++++++++ scripts/publish-mirror.sh | 199 ++++++++ .../tests/mirror-publish-workflow-verify.sh | 435 ++++++++++++++++ scripts/tests/publish-guard-verify.sh | 337 +++++++++++++ scripts/tests/publish-mirror-verify.sh | 155 ++++++ 10 files changed, 2189 insertions(+) create mode 100644 .github/workflows/mirror-publish.yml create mode 100644 .publish-forbidden create mode 100644 .publish-include create mode 100755 scripts/publish-guard.sh create mode 100755 scripts/publish-mirror.sh create mode 100644 scripts/tests/mirror-publish-workflow-verify.sh create mode 100755 scripts/tests/publish-guard-verify.sh create mode 100755 scripts/tests/publish-mirror-verify.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d52d01..ca61794 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -93,6 +93,14 @@ jobs: shellcheck --shell=bash --severity=error scripts/tests/pin-version-verify.sh bash -n scripts/check-pin-version.sh bash -n scripts/tests/pin-version-verify.sh + shellcheck --shell=bash --severity=warning scripts/publish-guard.sh + shellcheck --shell=bash --severity=warning scripts/publish-mirror.sh + shellcheck --shell=bash --severity=error scripts/tests/publish-guard-verify.sh + shellcheck --shell=bash --severity=error scripts/tests/publish-mirror-verify.sh + shellcheck --shell=bash --severity=error scripts/tests/mirror-publish-workflow-verify.sh + bash -n scripts/tests/mirror-publish-workflow-verify.sh + bash -n scripts/publish-guard.sh + bash -n scripts/publish-mirror.sh # format.sh's own fail-closed properties. Formatters are stubbed, so this is # hermetic and needs no Go toolchain — which is why it lives in this job # rather than Lint. It exists because the first cut of format.sh reported @@ -127,6 +135,23 @@ jobs: # pin-version-drift.yml, which must never gate a PR (backend#2704 / #1009). - name: Pin-version watcher harness (drift reddens / fail-closed) run: bash scripts/tests/pin-version-verify.sh + # The mirror-publish guard (scripts/publish-guard.sh) and publisher + # (scripts/publish-mirror.sh): each guard reddens on the thing it claims + # to catch (a forbidden path, a forbidden string, a missing scanner), an + # empty or unreadable list is "could not tell", and the publisher refuses + # an unset or self-pointing mirror. gitleaks is a PATH shim here, so this + # is hermetic; the workflow installs the real, pinned binary. + - name: Mirror-publish guard harness (refusals named / fail-closed) + run: bash scripts/tests/publish-guard-verify.sh + - name: Mirror-publish publisher harness (target / tree / release) + run: bash scripts/tests/publish-mirror-verify.sh + # The decisions mirror-publish.yml takes in its own step bodies — a + # prerelease keeps the mirror's default branch, the release tag is fetched + # as data only at the expected commit, no checkout takes an untrusted + # ref, a publisher refusal reaches the step log. The step bodies are read + # out of the YAML and executed with `gh` shimmed, so this is hermetic. + - name: Mirror-publish workflow harness (step bodies / shape / mutations) + run: bash scripts/tests/mirror-publish-workflow-verify.sh test: timeout-minutes: 15 diff --git a/.github/workflows/mirror-publish.yml b/.github/workflows/mirror-publish.yml new file mode 100644 index 0000000..66f56d7 --- /dev/null +++ b/.github/workflows/mirror-publish.yml @@ -0,0 +1,452 @@ +# Mirror publish — feed the public, deliverable-only mirror of this repository. +# +# This repository is the DEVELOPMENT repo. Its public face is a separate mirror +# repository that carries only the deliverable: README / LICENSE / user docs on +# its default branch, and each GitHub release with its assets — the signed +# binaries, SHA256SUMS and the two installers. No Go source ever leaves. Nothing reaches the mirror except through this +# workflow, and nothing leaves this workflow except what scripts/publish-guard.sh +# staged and cleared — allowlist (.publish-include), forbidden paths and strings +# (.publish-forbidden), gitleaks. The guard's header states the rules; every +# guard fails closed, and "could not tell" never publishes. +# +# Triggers +# workflow_run after "Release" completes successfully. NOT on +# `release: published`: softprops/action-gh-release creates +# the release and then uploads the assets, so that event +# fires before the eight binaries and SHA256SUMS are all +# attached — a mirror cut then would copy a release with +# half its assets. The completed release workflow is the +# moment every asset exists. head_branch of a tag-push run +# is the tag (measured on v0.10.25 / -rc.2), head_sha the +# commit it points at; both are checked below. +# workflow_dispatch `dry-run` (default TRUE) runs every guard, prints the +# staged file list and stops. `tag` names a published +# release whose assets are staged too (empty = tree only). +# `dry-run: false` with a tag publishes; it still refuses +# while no mirror is configured. `strict` (default FALSE) +# promotes the guard's report tier to refusal (below). +# +# String tiers +# .publish-forbidden splits its needles in two. [strings-refuse] (mailboxes, +# cloud account identifiers, the private tenant needles) refuses on a hit. +# [strings-report] (internal ticket references, non-production hostnames) is +# COUNTED and printed — per-needle totals, ten most-hit files — but refuses +# only when the guard runs with --strict. This workflow passes --strict when +# the dispatch input `strict` is true OR the repository variable +# PUBLISH_STRICT is "true"; the variable is what arms the workflow_run path, +# which has no inputs. Flip the variable the day the decision to strip the +# report tier from the deliverable is taken. +# +# What runs and what is data +# The guard, the publisher and the policy lists (.publish-include, +# .publish-forbidden) are read from THIS workflow's own commit (github.sha: +# the default branch for workflow_run, the dispatched branch for +# workflow_dispatch) — the one checkout with a trusted ref. The release tag +# is fetched separately as DATA: its tree is staged and scanned, never +# executed, and it is fetched only after two checks — the tag must be the +# release's own tag_name, and it must resolve to the commit the release run +# ran on (workflow_run.head_sha; for a dispatch, the commit GitHub reports +# for the tag). A tag that names a branch, a moved tag, or a release whose +# tag differs from the run's are all refused before anything is read. +# +# Prereleases +# A prerelease (-rc.N) mirrors ONLY its GitHub release, marked prerelease, +# pinned to the mirror's current default-branch head. The default branch is +# NOT pushed: it is what the README and the installer point at, and it keeps +# the last stable release. A prerelease therefore cannot be the first thing +# published to an empty mirror — the run refuses and says so. +# +# Older stable releases +# The tree replaces the mirror's default branch only when the tag is the +# NEWEST stable release of this repository (GitHub's `releases/latest`: the +# most recent published, non-prerelease release by commit date). A Release +# re-run, or a dispatch, of an older stable tag would otherwise roll the +# public README and docs back to that tag's tree — and, with the release +# already mirrored, refuse right after, leaving the rollback in place and +# paired with nothing. Such a tag mirrors only its GitHub release, pinned +# like a prerelease to the default branch's current head. If the newest +# stable release cannot be read, the run refuses rather than guess. +# +# Target +# The mirror is named by the Actions VARIABLE `MIRROR_REPO` (a bare repo name +# in this organisation), or the `mirror-repo` dispatch input. It has NO +# default: until the mirror exists the job refuses to publish, and it always +# refuses a target equal to this repository — publishing onto the source +# would replace the default branch you are standing on. +# +# Credentials +# RELEASE_TRAIN_APP_ID / RELEASE_TRAIN_APP_PRIVATE_KEY mint an installation +# token scoped to the mirror only (permission contents:write); the default +# GITHUB_TOKEN only reads this repo's release. PUBLISH_FORBIDDEN_TENANTS +# carries the private needles for the string scan (one extended regex per +# line — see .publish-forbidden for why they are not committed); the guard +# refuses to run the scan without it. +name: Mirror publish + +on: + workflow_run: + workflows: ["Release"] + types: [completed] + workflow_dispatch: + inputs: + tag: + description: "Published release tag to mirror (vX.Y.Z or vX.Y.Z-rc.N). Empty: stage the tree only." + type: string + default: "" + dry-run: + description: "Run every guard and print the staged file list without publishing" + type: boolean + default: true + mirror-repo: + description: "Mirror repository name in this organisation (overrides the MIRROR_REPO variable)" + type: string + default: "" + strict: + description: "Promote the guard's [strings-report] tier to refusal (--strict); the PUBLISH_STRICT variable does the same for every run" + type: boolean + default: false + +permissions: + contents: read + +jobs: + publish: + name: Guard, then publish to the mirror + # A failed or cancelled release run publishes nothing; there is nothing to + # mirror and a red run here would only point at the wrong workflow. + if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + # One publish at a time, never cancelled midway: a half-pushed mirror is + # worse than a late one. + concurrency: + group: mirror-publish + cancel-in-progress: false + env: + # Every event field is read through env, never interpolated into a + # script: a tag or branch name is attacker-shaped input. + EVENT_NAME: ${{ github.event_name }} + INPUT_TAG: ${{ inputs.tag }} + INPUT_DRY_RUN: ${{ inputs.dry-run }} + INPUT_MIRROR: ${{ inputs.mirror-repo }} + INPUT_STRICT: ${{ inputs.strict }} + RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + RUN_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + VAR_MIRROR: ${{ vars.MIRROR_REPO }} + VAR_STRICT: ${{ vars.PUBLISH_STRICT }} + steps: + - name: Check out the publishing tooling (this workflow's own commit) + # No `ref:` — github.sha, the commit this workflow file came from. The + # guard, the publisher and the policy lists run from here and only + # here; the release tag is fetched below into its own directory as + # data. + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Resolve what to publish + id: plan + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_run" ]; then + TAG="$RUN_HEAD_BRANCH"; DRY_RUN=false + else + TAG="$INPUT_TAG"; DRY_RUN="$INPUT_DRY_RUN" + fi + # Anything that is not exactly "false" is a dry run: fail closed. + [ "$DRY_RUN" = "false" ] || DRY_RUN=true + # --strict from the dispatch input or the repository variable; either + # alone arms it, and only the exact string "true" counts. + STRICT=false + if [ "$INPUT_STRICT" = "true" ] || [ "$VAR_STRICT" = "true" ]; then STRICT=true; fi + PRERELEASE=false; PUBLISH_TREE=true; EXPECT_SHA="" + if [ -n "$TAG" ]; then + if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then + echo "::error::'$TAG' is not a release tag — refusing to mirror it (a workflow_run whose head is a branch, or a mistyped dispatch)." + exit 1 + fi + # The release must be PUBLISHED here before it can be mirrored, and + # it must be the release OF this tag: a run whose head_branch names + # one tag while the release object carries another is refused. + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json tagName,isDraft,isPrerelease >"$RUNNER_TEMP/release.json"; then + echo "::error::no release '$TAG' on $GITHUB_REPOSITORY — nothing to mirror." + exit 1 + fi + RELEASE_TAG="$(jq -r .tagName "$RUNNER_TEMP/release.json")" + if [ "$RELEASE_TAG" != "$TAG" ]; then + echo "::error::release '$TAG' reports tag_name '$RELEASE_TAG' — the tag and the release disagree, refusing." + exit 1 + fi + if [ "$(jq -r .isDraft "$RUNNER_TEMP/release.json")" != "false" ]; then + echo "::error::release '$TAG' is a draft — only published releases are mirrored." + exit 1 + fi + PRERELEASE="$(jq -r .isPrerelease "$RUNNER_TEMP/release.json")" + # The tree push is armed only by an explicit `false`: a missing or + # malformed isPrerelease is refused, never read as "stable". + case "$PRERELEASE" in + true|false) ;; + *) echo "::error::release '$TAG' reports isPrerelease '$PRERELEASE' — not a boolean, refusing: only an explicit false may replace the mirror's default branch."; exit 1 ;; + esac + # The commit the tag MUST resolve to when it is fetched below. From + # a workflow_run that is the commit the release run ran on; from a + # dispatch it is the commit GitHub reports for the tag right now. + if [ "$EVENT_NAME" = "workflow_run" ]; then + EXPECT_SHA="$RUN_HEAD_SHA" + else + EXPECT_SHA="$(gh api "repos/$GITHUB_REPOSITORY/commits/$TAG" --jq .sha)" || EXPECT_SHA="" + fi + if [[ ! "$EXPECT_SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::cannot determine the commit release '$TAG' was cut from (got '${EXPECT_SHA:-}') — refusing to fetch the tag." + exit 1 + fi + if [ "$PRERELEASE" = "true" ]; then + PUBLISH_TREE=false + echo "::notice::'$TAG' is a prerelease: only its GitHub release is mirrored (marked prerelease). The mirror's default branch is not pushed — it keeps the last stable release." + else + # Only the NEWEST stable release replaces the mirror's default + # branch: a re-run or a dispatch of an older stable tag must not + # roll the public README and docs back. `releases/latest` is the + # newest published, non-prerelease release; unreadable = refuse. + LATEST_TAG="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" || LATEST_TAG="" + if [ -z "$LATEST_TAG" ]; then + echo "::error::cannot determine the newest stable release of $GITHUB_REPOSITORY — refusing to decide whether '$TAG' may replace the mirror's default branch." + exit 1 + fi + if [ "$LATEST_TAG" != "$TAG" ]; then + PUBLISH_TREE=false + echo "::notice::'$TAG' is not the newest stable release ($LATEST_TAG is): only its GitHub release is mirrored. The mirror's default branch is not pushed — it keeps the newest stable release." + fi + fi + else + if [ "$DRY_RUN" != "true" ]; then + echo "::error::a real publish needs a release tag; a tree-only run is dry-run only." + exit 1 + fi + fi + { + echo "tag=$TAG" + echo "dry_run=$DRY_RUN" + echo "expect_sha=$EXPECT_SHA" + echo "prerelease=$PRERELEASE" + echo "publish_tree=$PUBLISH_TREE" + echo "strict=$STRICT" + } >>"$GITHUB_OUTPUT" + echo "plan: event=$EVENT_NAME tag='${TAG:-}' expect_sha=${EXPECT_SHA:-} dry_run=$DRY_RUN prerelease=$PRERELEASE publish_tree=$PUBLISH_TREE strict=$STRICT" + + - name: Fetch the release tag as data + # Into a detached worktree under RUNNER_TEMP, outside the tooling + # checkout. Nothing in it is executed: the guard reads it and copies + # the allowlisted files out. The fetched tag must resolve to exactly + # the commit the plan step expects, or the run refuses — a tag moved + # after the release, or a release run on a different commit, does not + # get mirrored. `--no-tags` so only the one named ref arrives. + if: steps.plan.outputs.tag != '' + id: src + env: + TAG: ${{ steps.plan.outputs.tag }} + EXPECT_SHA: ${{ steps.plan.outputs.expect_sha }} + run: | + set -euo pipefail + if ! git fetch --no-tags --depth 1 origin "refs/tags/$TAG"; then + echo "::error::could not fetch tag '$TAG' from origin — refusing to mirror a release whose tag is not here." + exit 1 + fi + SHA="$(git rev-parse 'FETCH_HEAD^{commit}')" + if [ "$SHA" != "$EXPECT_SHA" ]; then + echo "::error::tag '$TAG' resolves to $SHA but the release was cut at $EXPECT_SHA — the tag has moved or the run is not this release's; refusing." + exit 1 + fi + git worktree add --detach "$RUNNER_TEMP/release-src" "$SHA" + echo "dir=$RUNNER_TEMP/release-src" >>"$GITHUB_OUTPUT" + echo "release source: $TAG at $SHA (data only) in $RUNNER_TEMP/release-src" + + - name: Install gitleaks (pinned, checksum-verified) + # Not preinstalled on ubuntu-latest. One pinned release, verified against + # its published checksum before it runs: the guard treats a missing + # scanner as "could not tell", so a failed install here is a red run, + # never a silent skip. + env: + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: "551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb" + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/bin" + curl -fsSL --tlsv1.2 --retry 3 --connect-timeout 10 --max-time 120 \ + -o "$RUNNER_TEMP/gitleaks.tgz" \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" + echo "${GITLEAKS_SHA256} $RUNNER_TEMP/gitleaks.tgz" | sha256sum -c - + tar -xzf "$RUNNER_TEMP/gitleaks.tgz" -C "$RUNNER_TEMP/bin" gitleaks + chmod 0755 "$RUNNER_TEMP/bin/gitleaks" + echo "$RUNNER_TEMP/bin" >>"$GITHUB_PATH" + "$RUNNER_TEMP/bin/gitleaks" version + + - name: Write the private needle list + # The secret is written to a file, never echoed. An unset secret yields + # an empty file, which the guard refuses as "could not tell". + env: + PUBLISH_FORBIDDEN_TENANTS: ${{ secrets.PUBLISH_FORBIDDEN_TENANTS }} + run: | + set -euo pipefail + printf '%s\n' "$PUBLISH_FORBIDDEN_TENANTS" | sed '/^[[:space:]]*$/d' >"$RUNNER_TEMP/tenants.txt" + echo "private needle list: $(grep -c . "$RUNNER_TEMP/tenants.txt" || true) entr(y/ies)" + + - name: Download the release assets + if: steps.plan.outputs.tag != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.plan.outputs.tag }} + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/assets" + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir "$RUNNER_TEMP/assets" + ls -l "$RUNNER_TEMP/assets" + + - name: Guard the tree and the release assets + # --source is the release worktree (the tooling checkout itself for a + # tree-only dry run); the allowlist and the forbidden list are always + # the tooling checkout's, so the policy that runs is the one reviewed + # on this branch, whatever the tag carries. + # Actions runs this body with -e; the guard's exit status is caught + # with `|| rc=$?` so a refusal still reaches the step summary and the + # step then exits with the guard's own status. + id: guard-tree + env: + TAG: ${{ steps.plan.outputs.tag }} + STRICT: ${{ steps.plan.outputs.strict }} + SRC_DIR: ${{ steps.src.outputs.dir }} + run: | + set -uo pipefail + args=(--source "${SRC_DIR:-.}" --include "$GITHUB_WORKSPACE/.publish-include" --forbidden "$GITHUB_WORKSPACE/.publish-forbidden" + --out "$RUNNER_TEMP/stage" --extra-forbidden "$RUNNER_TEMP/tenants.txt") + [ -z "$TAG" ] || args+=(--assets "$RUNNER_TEMP/assets") + [ "$STRICT" != "true" ] || args+=(--strict) + rc=0 + bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-tree.log" || rc=$? + { + echo "## Mirror publish — tree${TAG:+ + release $TAG}" + echo + echo '```' + cat "$RUNNER_TEMP/guard-tree.log" + echo '```' + } >>"$GITHUB_STEP_SUMMARY" + exit "$rc" + + - name: Dry run — stop here + if: steps.plan.outputs.dry_run == 'true' + run: | + set -euo pipefail + MIRROR="${INPUT_MIRROR:-$VAR_MIRROR}" + echo "dry run: every guard passed; nothing was published." + if [ -z "$MIRROR" ]; then + echo "::notice::MIRROR_REPO is unset — a real run would refuse at the target check until the mirror repository exists and is named." + else + echo "a real run would publish to: $GITHUB_REPOSITORY_OWNER/$MIRROR" + fi + + - name: Resolve the mirror repository + # Run directly, never through `$(...)`: a refusal is a `::error::` line + # on stdout, and a capture would swallow it before `set -e` exits. The + # result (repo=, name=) is written by the script to $GITHUB_OUTPUT. + if: steps.plan.outputs.dry_run != 'true' + id: target + run: | + set -euo pipefail + bash scripts/publish-mirror.sh target --mirror "${INPUT_MIRROR:-$VAR_MIRROR}" --source-repo "$GITHUB_REPOSITORY" --output "$GITHUB_OUTPUT" + + - name: Mint a token scoped to the mirror + if: steps.plan.outputs.dry_run != 'true' + id: 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: ${{ steps.target.outputs.name }} + permission-contents: write + + - name: Confirm the mirror is a different, existing repository + if: steps.plan.outputs.dry_run != 'true' + id: mirror + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + REPO: ${{ steps.target.outputs.repo }} + run: | + set -euo pipefail + gh api "repos/$REPO" --jq '{full_name, default_branch, visibility}' >"$RUNNER_TEMP/mirror.json" + FULL="$(jq -r .full_name "$RUNNER_TEMP/mirror.json")" + if [ "$(printf '%s' "$FULL" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$GITHUB_REPOSITORY" | tr '[:upper:]' '[:lower:]')" ]; then + echo "::error::mirror '$FULL' resolves to this repository — refusing." + exit 1 + fi + echo "default_branch=$(jq -r .default_branch "$RUNNER_TEMP/mirror.json")" >>"$GITHUB_OUTPUT" + cat "$RUNNER_TEMP/mirror.json" + + - name: Push the tree to the mirror's default branch + # The newest stable release only: a prerelease or an older stable tag + # never replaces what customers install from (publish_tree=false, see + # the plan step). + if: steps.plan.outputs.dry_run != 'true' && steps.plan.outputs.publish_tree == 'true' + id: push + env: + MIRROR_TOKEN: ${{ steps.token.outputs.token }} + REPO: ${{ steps.target.outputs.repo }} + BRANCH: ${{ steps.mirror.outputs.default_branch }} + TAG: ${{ steps.plan.outputs.tag }} + run: | + set -euo pipefail + # Credentials come from the helper, read from the environment; the + # token is never part of a URL or a command line. The single quotes + # are the point: $MIRROR_TOKEN expands when git runs the helper. + # shellcheck disable=SC2016 + git config --global credential.helper '!f() { printf "username=x-access-token\npassword=%s\n" "$MIRROR_TOKEN"; }; f' + # Direct, not captured: a rejected push or a refused stage annotates + # the log; the result (result=, sha=) lands in $GITHUB_OUTPUT. + bash scripts/publish-mirror.sh tree --stage "$RUNNER_TEMP/stage/tree" --repo "$REPO" --branch "$BRANCH" --message "Publish $TAG" --output "$GITHUB_OUTPUT" + + - name: Keep the mirror's default branch, pin the release to its head + # A prerelease or an older stable tag: nothing was pushed, so the + # release is created at the commit the mirror's default branch already + # has (the newest stable publish). An empty mirror has no such commit: + # such a release cannot be the first publish, and the run says so + # instead of inventing a target. + if: steps.plan.outputs.dry_run != 'true' && steps.plan.outputs.publish_tree != 'true' + id: keep + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + REPO: ${{ steps.target.outputs.repo }} + BRANCH: ${{ steps.mirror.outputs.default_branch }} + TAG: ${{ steps.plan.outputs.tag }} + run: | + set -euo pipefail + if ! SHA="$(gh api "repos/$REPO/commits/$BRANCH" --jq .sha)" || [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::'$TAG' does not replace the mirror's default branch (a prerelease, or not the newest stable release) and the mirror has no commit on '$BRANCH' to pin it to — it cannot be the first publish to an empty mirror; publish the newest stable release first." + exit 1 + fi + echo "sha=$SHA" >>"$GITHUB_OUTPUT" + echo "release $TAG: default branch '$BRANCH' left untouched; release will be pinned to $SHA" + + - name: Create the release on the mirror + if: steps.plan.outputs.dry_run != 'true' + env: + GH_TOKEN: ${{ steps.token.outputs.token }} + REPO: ${{ steps.target.outputs.repo }} + TAG: ${{ steps.plan.outputs.tag }} + SHA: ${{ steps.push.outputs.sha || steps.keep.outputs.sha }} + PRERELEASE: ${{ steps.plan.outputs.prerelease }} + run: | + set -euo pipefail + # Fixed notes, not the source release's generated ones: those list + # merged pull requests by title, which is development history, not + # the deliverable. + { + echo "tracebloc CLI $TAG." + echo + echo "Install with the one-liner in the README, or download a binary below and" + echo "verify it against SHA256SUMS and its cosign .sig/.cert (recipe in the README)." + } >"$RUNNER_TEMP/notes.md" + args=(release --tag "$TAG" --repo "$REPO" --target "$SHA" --assets "$RUNNER_TEMP/stage/assets" --notes "$RUNNER_TEMP/notes.md") + [ "$PRERELEASE" != "true" ] || args+=(--prerelease) + bash scripts/publish-mirror.sh "${args[@]}" diff --git a/.publish-forbidden b/.publish-forbidden new file mode 100644 index 0000000..41f4d35 --- /dev/null +++ b/.publish-forbidden @@ -0,0 +1,79 @@ +# .publish-forbidden — what must never reach the public mirror, even if the +# allowlist (.publish-include) let it through by mistake. +# +# Read by scripts/publish-guard.sh. Four sections; `#` starts a comment. A +# header the guard does not know, a needle listed in both string tiers, or an +# empty [strings-refuse] is refused as "could not tell" (exit 2). +# +# [paths] gitignore-style names. A pattern containing `/` is +# anchored to the staged root; one without matches ANY path +# component; a trailing `/` means "as a directory". +# [strings-refuse] extended regexes, matched case-insensitively against +# every staged TEXT file. A hit REFUSES the publish and +# names the file and line (never the matched text). +# [strings-report] same syntax. Hits are COUNTED and printed (per-needle +# totals, ten most-hit files) but do not refuse — unless +# the guard runs with --strict, which promotes this tier +# to refusal. A needle moves up to [strings-refuse] the +# day it is decided the mirror must never carry it. +# [allow] exact tokens removed from a line before a needle is +# re-tested, so a line is spared only when the allowed +# token was the whole reason it hit. +# +# This file is ONE list read by both the guard and its tests; the tests write +# their own inputs and never iterate this file to check itself. + +[paths] +tests/ +scripts/tests/ +ci/ +.github/ +docs/rfcs/ +docs/migration-tools/ +CLAUDE.md +STYLE.md +Makefile +.cursor/ +*.go +go.mod +go.sum +__pycache__ +.DS_Store +.env* +*.pem +*.key +kubeconfig* + +[strings-refuse] +# Mailboxes (the public support address is spared under [allow]). +[A-Za-z0-9._%+-]+@tracebloc\.io +# AWS account identifiers and ARNs. +arn:aws: +[0-9]{12}\.dkr\.ecr\. +# +# CUSTOMER AND TENANT IDENTIFIERS ARE DELIBERATELY NOT LISTED HERE. This file +# is public, and a list of customer names would itself be the disclosure the +# scan exists to prevent. Those needles are supplied privately at publish time: +# the workflow writes the PUBLISH_FORBIDDEN_TENANTS secret (one needle per line, +# same regex syntax) to a file and passes it as --extra-forbidden; they join +# this tier. The guard refuses to run the scan when that list is missing or +# empty. + +[strings-report] +# Internal tracker and RFC identifiers — a reader of the mirror cannot open +# them. Counted until the decision to strip them from the deliverable (or to +# accept them) is taken; --strict refuses them. +backend# +rfcs# +RFC-0 +RFC-BACKEND +e2e-test-agent# +tracebloc/backend +# Non-production tracebloc hosts; same decision pending. +dev-api\.tracebloc\.io +stg-api\.tracebloc\.io +dev\.tracebloc\.io +stg\.tracebloc\.io + +[allow] +support@tracebloc\.io diff --git a/.publish-include b/.publish-include new file mode 100644 index 0000000..e88a5f8 --- /dev/null +++ b/.publish-include @@ -0,0 +1,16 @@ +# .publish-include — what the public mirror of this repo MAY carry. +# +# Read by scripts/publish-guard.sh. One glob per line; `#` starts a comment. +# `*` and `?` do not cross `/`, `**` does; a leading `!` takes matching files +# back out. Only tracked files are considered. Anything not matched here is +# excluded by construction — .publish-forbidden is the second lock. +# +# The mirror is README + releases. The binaries, SHA256SUMS, signatures and +# the two installers travel as RELEASE ASSETS (copied from this repo's release +# by the publish workflow and scanned by the same guard), never as tree files. +# No Go source, no Makefile, no workflows: the forbidden list refuses each of +# those by name should a line here ever widen. +README.md +LICENSE +# The user docs README links to. One level only: docs/rfcs/ stays home. +docs/*.md diff --git a/scripts/RELEASE_CHECKLIST.md b/scripts/RELEASE_CHECKLIST.md index e42b6ca..38d6ec2 100644 --- a/scripts/RELEASE_CHECKLIST.md +++ b/scripts/RELEASE_CHECKLIST.md @@ -28,6 +28,26 @@ have to reverse-engineer the surface area on release day. and all artifacts attached. `prerelease=true` if the tag contains a `-` (e.g. `v0.1.0-rc1`). +7. `.github/workflows/mirror-publish.yml` fires when the Release + workflow completes. It stages the public deliverable (README, + LICENSE, `docs/*.md` per `.publish-include`; the release assets) + through `scripts/publish-guard.sh` — allowlist, forbidden paths, + forbidden strings, gitleaks, all fail-closed — and pushes it, plus + a copy of the release, to the public mirror named by the + `MIRROR_REPO` variable. Until that variable is set the job refuses + to publish; `Actions → Mirror publish → Run workflow` with + `dry-run: true` shows what would ship. The string scan has two + tiers: `[strings-refuse]` hits refuse; `[strings-report]` hits + (internal ticket references, non-production hostnames) are counted + and printed with the most-hit files, and refuse only under the + `strict` input or the `PUBLISH_STRICT=true` repository variable. + The guard and publisher run from the workflow's own commit; the + release tag is fetched separately as data and refused unless it + resolves to the commit the Release run ran on. A prerelease + (`-rc.N`) mirrors only its GitHub release, marked prerelease and + pinned to the mirror's current default-branch head — the mirror's + default branch keeps the last stable release. + GitHub Releases plus the cosign-verified `install.sh` are the install path — a Homebrew tap and the `install.tracebloc.io` vanity URL were considered and dropped diff --git a/scripts/publish-guard.sh b/scripts/publish-guard.sh new file mode 100755 index 0000000..ef1aae2 --- /dev/null +++ b/scripts/publish-guard.sh @@ -0,0 +1,471 @@ +#!/usr/bin/env bash +# ============================================================================= +# publish-guard.sh — stage the public deliverable of this repo and refuse +# anything else. +# +# The public mirror of this repo carries a DELIVERABLE, not the source tree. +# This script builds that deliverable in a clean directory from an explicit +# allowlist, then runs four guards over what it staged. Nothing outside the +# allowlist can be staged (exclusion by construction), and four independent +# scans stand between the staged tree and the push: +# +# 1. [allowlist] .publish-include names what MAY ship. Tracked files +# only (`git ls-files`), matched by glob; a `!glob` +# line takes files back out again. +# 2. [forbidden-paths] .publish-forbidden `[paths]`: names that must never +# be in the staged tree even if allowlisted by +# mistake (gitignore-style matching). +# 3. [forbidden-strings] .publish-forbidden needles (extended regex, +# case-insensitive) scanned over every staged text +# file, in two tiers: +# [strings-refuse] a hit refuses the publish +# (mailboxes, cloud account +# identifiers; the private +# needles from --extra-forbidden +# join this tier and are named +# `private needle #N` in every +# line this script prints or +# writes — the pattern itself +# never reaches a log). +# [strings-report] hits are COUNTED and printed — +# per-needle totals and the ten +# most-hit files — but refuse +# only under --strict. Internal +# ticket references and +# non-production hostnames live +# here until the decision to +# strip them is taken; --strict +# arms that decision. +# `[allow]` entries are exact tokens spared before a +# needle is re-tested (a public support mailbox +# beside a rule that bans every other mailbox): a +# token is stripped only as a whole word, case- +# insensitively like the scan — `devsupport@…` is +# not spared by `support@…`. +# A needle may sit in one tier only, [strings-refuse] +# may not be empty, and a section header the guard +# does not know is refused: each of those is a list +# the guard cannot vouch for (exit 2). +# 4. [gitleaks] gitleaks detect --no-git --redact over everything +# staged, default rules. +# +# FAIL CLOSED. Exit 0 only when every guard RAN and every guard PASSED. +# exit 1 a guard REFUSED — the message names the guard and the rule. +# exit 2 COULD NOT TELL — unreadable or empty allowlist / forbidden list, +# a malformed forbidden list (unknown section, a needle in both +# tiers, an empty refuse tier), zero tracked files, an allowlist +# that matched nothing, a symlink in the allowlisted set, a missing +# or erroring scanner, a guard that did not run, a non-empty --out. +# "Cannot tell" is never clean. +# Every guard runs even after an earlier one has refused, so one run reports +# everything; the exit status is the worst verdict seen. +# +# Usage: +# publish-guard.sh --source DIR --out DIR +# [--include FILE] default DIR/.publish-include +# [--forbidden FILE] default DIR/.publish-forbidden +# [--extra-forbidden FILE] more refuse-tier needles (repeat +# as needed); must be readable +# and non-empty +# [--assets DIR] release assets to publish next +# to the tree; guards 2–4 scan +# them too +# [--strict] a [strings-report] hit refuses +# instead of being counted +# +# Output: one line per guard, the staged file list, a final verdict. +# OUT/tree holds the staged tree, OUT/assets the assets; OUT must not exist or +# must be empty (a stale staging directory could carry a file no guard read). +# A full findings report is written to OUT/publish-guard-report.txt. +# +# Environment (tests only): PUBLISH_GUARD_GITLEAKS names the gitleaks binary. +# ============================================================================= +set -uo pipefail + +SOURCE=""; OUT=""; INCLUDE=""; FORBIDDEN=""; ASSETS=""; STRICT=0 +EXTRA_FORBIDDEN=() +while [ "$#" -gt 0 ]; do + case "$1" in + --source) SOURCE="${2:-}"; shift 2 ;; + --out) OUT="${2:-}"; shift 2 ;; + --include) INCLUDE="${2:-}"; shift 2 ;; + --forbidden) FORBIDDEN="${2:-}"; shift 2 ;; + --extra-forbidden) EXTRA_FORBIDDEN+=("${2:-}"); shift 2 ;; + --assets) ASSETS="${2:-}"; shift 2 ;; + --strict) STRICT=1; shift ;; + -h|--help) sed -n '2,/^# ====/p' "$0" | sed 's/^# \{0,2\}//'; exit 0 ;; + *) echo "publish-guard: unknown argument '$1'" >&2; exit 2 ;; + esac +done + +# ---- verdict bookkeeping ----------------------------------------------------- +# WORST is the exit status: 0 clean, 1 refused, 2 could not tell. RAN counts the +# guards that reached a verdict; the final check refuses to report green unless +# all four did — a refactor that drops a stage must not look like a clean run. +WORST=0 +RAN=0 +GUARDS_EXPECTED=4 +worsen() { [ "$1" -gt "$WORST" ] && WORST="$1"; return 0; } +# The workflow-command prefix goes to STDOUT: Actions reads ::error:: from +# stdout only. Plain lines are the guard's narration. +refuse() { echo "::error::publish-guard: [$1] REFUSED — $2"; worsen 1; } +cant_tell(){ echo "::error::publish-guard: [$1] COULD NOT TELL — $2 (never reported as clean)"; worsen 2; } +note() { echo "publish-guard: [$1] $2"; } +# A guard error before any guard can run: nothing to stage, nothing to report. +die2() { echo "::error::publish-guard: COULD NOT TELL — $1 (never reported as clean)"; exit 2; } + +[ -n "$SOURCE" ] || die2 "--source is required" +[ -n "$OUT" ] || die2 "--out is required" +[ -d "$SOURCE" ] || die2 "--source '$SOURCE' is not a directory" +SOURCE="$(cd "$SOURCE" && pwd)" +[ -n "$INCLUDE" ] || INCLUDE="$SOURCE/.publish-include" +[ -n "$FORBIDDEN" ] || FORBIDDEN="$SOURCE/.publish-forbidden" +if [ -e "$OUT" ]; then + [ -d "$OUT" ] || die2 "--out '$OUT' exists and is not a directory" + [ -z "$(ls -A "$OUT")" ] || die2 "--out '$OUT' is not empty; a stale staging directory could carry a file no guard read" +fi +mkdir -p "$OUT/tree" || die2 "cannot create '$OUT/tree'" +OUT="$(cd "$OUT" && pwd)" +TREE="$OUT/tree" + +# Scratch, armed only once it exists (a failed mktemp must not make the trap +# expand to `rm -rf /*`). +TMP="$(mktemp -d "${TMPDIR:-/tmp}/publish-guard.XXXXXX")" && [ -d "$TMP" ] || die2 "could not create a scratch directory" +trap 'rm -rf "$TMP"' EXIT +REPORT="$TMP/report.txt" +: >"$REPORT" + +# ---- list files: strip comments and blanks, keep order ------------------------ +# A section header is a line that is nothing but one bracketed token. The match +# is deliberately loose (`[strings refuse]`, `[Strings-Refuse]` are headers too) +# so a misspelt header is refused by name below instead of being read as a +# needle of the section before it. +SECTION_RE='^[[][^]]*[]][[:space:]]*$' # bracket expressions, so no awk escape processing applies +# read_list FILE SECTION — print the entries of SECTION ([paths] / +# [strings-refuse] / [strings-report] / [allow]) from a sectioned list file; +# SECTION "" prints every entry of a file that has no section headers (the +# allowlist, an --extra-forbidden list). +read_list() { + awk -v want="$2" -v hdr="$SECTION_RE" ' + /^[[:space:]]*(#|$)/ { next } + $0 ~ hdr { sec = $0; sub(/^\[/, "", sec); sub(/\].*$/, "", sec); next } + { line = $0; sub(/[[:space:]]+$/, "", line) + if (want == "" || sec == want) print line } + ' "$1" +} + +# The sections the forbidden list may declare. Both guards that read the list +# check every header against this set: a header the guard does not read would +# silently orphan the rules under it. +FORBIDDEN_SECTIONS="paths strings-refuse strings-report allow" +# forbidden_sections_ok GUARD — could-not-tell (and return 1) on the first +# header of $FORBIDDEN that is not one of FORBIDDEN_SECTIONS. +forbidden_sections_ok() { + local sec + while IFS= read -r sec; do + case " $FORBIDDEN_SECTIONS " in + *" $sec "*) ;; + *) cant_tell "$1" "'$FORBIDDEN' has an unknown section [$sec] — the guard reads only [${FORBIDDEN_SECTIONS// /] [}]"; return 1 ;; + esac + done < <(awk -v hdr="$SECTION_RE" '$0 ~ hdr { sec = $0; sub(/^\[/, "", sec); sub(/\].*$/, "", sec); print sec }' "$FORBIDDEN") + return 0 +} + +# glob_to_ere GLOB — an anchored extended regex for a path glob: `*` and `?` do +# not cross `/`, `**` does (`**/` also matches zero directories). Every other +# regex metacharacter in the glob is escaped, so a `.` in `*.go` is a dot. +glob_to_ere() { + local g="$1" out="" i c n + n=${#g} + for ((i = 0; i < n; i++)); do + c="${g:i:1}" + case "$c" in + '*') + if [ "${g:i+1:1}" = '*' ]; then + if [ "${g:i+2:1}" = '/' ]; then out+='(.*/)?'; i=$((i + 2)); else out+='.*'; i=$((i + 1)); fi + else + out+='[^/]*' + fi ;; + '?') out+='[^/]' ;; + '['|']'|'.'|'^'|'$'|'+'|'('|')'|'{'|'}'|'|'|'\') out+="\\$c" ;; + *) out+="$c" ;; + esac + done + printf '^%s$' "$out" +} + +# ---- guard 1: allowlist --------------------------------------------------------- +guard_allowlist() { + local g="allowlist" n_inc=0 n_exc=0 line re + local -a inc_re=() exc_re=() + if [ ! -r "$INCLUDE" ]; then cant_tell "$g" "allowlist '$INCLUDE' is missing or unreadable"; RAN=$((RAN + 1)); return; fi + while IFS= read -r line; do + case "$line" in + '!'*) exc_re+=("$(glob_to_ere "${line#!}")"); n_exc=$((n_exc + 1)) ;; + *) inc_re+=("$(glob_to_ere "$line")"); n_inc=$((n_inc + 1)) ;; + esac + done < <(read_list "$INCLUDE" "") + if [ "$n_inc" -eq 0 ]; then cant_tell "$g" "allowlist '$INCLUDE' lists no include entries — nothing may ship, so nothing can be vouched for"; RAN=$((RAN + 1)); return; fi + + # Tracked files only: an untracked file in the checkout is never a deliverable. + # A path containing a newline is unrepresentable in the line-oriented list + # below, so the NUL-separated count must equal the line count. + local listed nul_count line_count + listed="$TMP/tracked.txt" + if ! git -C "$SOURCE" -c core.quotePath=false ls-files >"$listed" 2>"$TMP/git.err"; then + cant_tell "$g" "git ls-files failed in '$SOURCE': $(tr '\n' ' ' <"$TMP/git.err")"; RAN=$((RAN + 1)); return + fi + nul_count="$(git -C "$SOURCE" ls-files -z | tr -cd '\0' | wc -c | tr -d ' ')" + line_count="$(wc -l <"$listed" | tr -d ' ')" + if [ "$line_count" -eq 0 ]; then cant_tell "$g" "'$SOURCE' has zero tracked files"; RAN=$((RAN + 1)); return; fi + if [ "$nul_count" != "$line_count" ]; then cant_tell "$g" "a tracked path contains a newline ($nul_count entries, $line_count lines) — cannot match it safely"; RAN=$((RAN + 1)); return; fi + + local staged=0 f matched + : >"$TMP/staged.txt" + while IFS= read -r f; do + matched=0 + for re in "${inc_re[@]}"; do [[ "$f" =~ $re ]] && { matched=1; break; }; done + [ "$matched" -eq 1 ] || continue + for re in "${exc_re[@]+"${exc_re[@]}"}"; do [[ "$f" =~ $re ]] && { matched=0; break; }; done + [ "$matched" -eq 1 ] || continue + if [ -L "$SOURCE/$f" ]; then cant_tell "$g" "'$f' is a symlink — a link can point outside the tree, so it is not staged"; RAN=$((RAN + 1)); return; fi + [ -f "$SOURCE/$f" ] || { cant_tell "$g" "tracked file '$f' is missing from the checkout"; RAN=$((RAN + 1)); return; } + mkdir -p "$TREE/$(dirname "$f")" || { cant_tell "$g" "cannot create '$TREE/$(dirname "$f")'"; RAN=$((RAN + 1)); return; } + cp -p "$SOURCE/$f" "$TREE/$f" || { cant_tell "$g" "cannot copy '$f'"; RAN=$((RAN + 1)); return; } + printf '%s\n' "$f" >>"$TMP/staged.txt" + staged=$((staged + 1)) + done <"$listed" + if [ "$staged" -eq 0 ]; then cant_tell "$g" "the allowlist matched none of the $line_count tracked files — a mirror with nothing in it is not a deliverable"; RAN=$((RAN + 1)); return; fi + note "$g" "staged $staged of $line_count tracked file(s) ($n_inc include, $n_exc exclude pattern(s)):" + sort "$TMP/staged.txt" | sed 's/^/ /' + RAN=$((RAN + 1)) +} + +# ---- assets ---------------------------------------------------------------------- +stage_assets() { + [ -n "$ASSETS" ] || return 0 + [ -d "$ASSETS" ] || die2 "--assets '$ASSETS' is not a directory" + local n + n="$(find "$ASSETS" -mindepth 1 -maxdepth 1 -type f | wc -l | tr -d ' ')" + [ "$n" -gt 0 ] || die2 "--assets '$ASSETS' holds no files — a release with no assets is not what a customer downloads" + [ "$(find "$ASSETS" -mindepth 1 -maxdepth 1 ! -type f | wc -l | tr -d ' ')" -eq 0 ] || die2 "--assets '$ASSETS' holds something other than plain files (a directory or a symlink)" + mkdir -p "$OUT/assets" && cp -p "$ASSETS"/* "$OUT/assets"/ || die2 "cannot copy assets from '$ASSETS'" + note "assets" "staged $n release asset(s):" + find "$OUT/assets" -mindepth 1 -maxdepth 1 -type f | sed "s|^$OUT/assets/||" | sort | sed 's/^/ /' +} + +# staged_paths — every staged path as `:` (area = tree or +# assets), one per line. +staged_paths() { + ( cd "$OUT" && find tree assets -type f 2>/dev/null ) | sed -E 's#^(tree|assets)/#\1:#' | sort +} + +# ---- guard 2: forbidden paths ---------------------------------------------------- +# gitignore-style: a pattern with a `/` inside it is anchored to the staged root +# (`scripts/tests/` matches only that directory); one without matches ANY path +# component (`tests/` matches `client/tests/x`, `*.go` matches `a/b/c.go`); a +# trailing `/` means "as a directory" (`tests/` does not match a file named +# tests). The staged area prefix (tree/, assets/) is not part of the path. +path_pattern_hits() { # $1 = pattern, reads staged paths on stdin, prints hits + local pat="$1" dir_only=0 anchored=0 re + case "$pat" in */) dir_only=1; pat="${pat%/}" ;; esac + pat="${pat#/}" + case "$pat" in */*) anchored=1 ;; esac + re="$(glob_to_ere "$pat")" + local entry p comp + local -a comps + while IFS= read -r entry; do + p="${entry#*:}" + if [ "$anchored" -eq 1 ]; then + if [ "$dir_only" -eq 0 ] && [[ "$p" =~ $re ]]; then printf '%s\n' "$entry"; continue; fi + [[ "$p/" == "${pat}/"* ]] && printf '%s\n' "$entry" + continue + fi + IFS='/' read -r -a comps <<<"$p" + local i last=$(( ${#comps[@]} - 1 )) + for i in "${!comps[@]}"; do + comp="${comps[$i]}" + [ "$dir_only" -eq 1 ] && [ "$i" -eq "$last" ] && continue + if [[ "$comp" =~ $re ]]; then printf '%s\n' "$entry"; break; fi + done + done +} + +guard_forbidden_paths() { + local g="forbidden-paths" n=0 pat hits total=0 + if [ ! -r "$FORBIDDEN" ]; then cant_tell "$g" "forbidden list '$FORBIDDEN' is missing or unreadable"; RAN=$((RAN + 1)); return; fi + forbidden_sections_ok "$g" || { RAN=$((RAN + 1)); return; } + read_list "$FORBIDDEN" paths >"$TMP/paths.txt" + n="$(grep -c . "$TMP/paths.txt" || true)" + if [ "$n" -eq 0 ]; then cant_tell "$g" "'$FORBIDDEN' has no [paths] entries — a scan with no rules proves nothing"; RAN=$((RAN + 1)); return; fi + staged_paths >"$TMP/all.txt" + while IFS= read -r pat; do + hits="$(path_pattern_hits "$pat" <"$TMP/all.txt")" + [ -n "$hits" ] || continue + total=$((total + $(printf '%s\n' "$hits" | grep -c .))) + refuse "$g" "forbidden path pattern '$pat' matched:" + printf '%s\n' "$hits" | sed 's/^/ /' | tee -a "$REPORT" + done <"$TMP/paths.txt" + [ "$total" -gt 0 ] || note "$g" "clean ($n pattern(s) against $(grep -c . "$TMP/all.txt") staged path(s))" + RAN=$((RAN + 1)) +} + +# ---- guard 3: forbidden strings -------------------------------------------------- +# Two tiers over the same scan. A [strings-refuse] needle (or any needle from +# --extra-forbidden) refuses on a hit. A [strings-report] needle is counted and +# printed — per-needle totals and the ten most-hit files — and refuses only +# under --strict: the tier can be measured on the real deliverable before the +# decision to strip it is taken, and one flag arms that decision. +# Text files only (`grep -I`): a binary asset is opaque to a string scan; its +# integrity is the release's own SHA256SUMS + signature. The count of binaries +# skipped is printed so "scanned everything" and "skipped half" read differently. +guard_forbidden_strings() { + local g="forbidden-strings" needle rc hits n_refuse n_report n_allow=0 extra dup + if [ ! -r "$FORBIDDEN" ]; then cant_tell "$g" "forbidden list '$FORBIDDEN' is missing or unreadable"; RAN=$((RAN + 1)); return; fi + forbidden_sections_ok "$g" || { RAN=$((RAN + 1)); return; } + read_list "$FORBIDDEN" strings-refuse >"$TMP/needles-refuse.txt" + read_list "$FORBIDDEN" strings-report >"$TMP/needles-report.txt" + read_list "$FORBIDDEN" allow >"$TMP/allow.txt" + # One tier per needle: the same text in both would be refused by one loop and + # counted by the other, and whichever the reader saw first would be the rule. + dup="$(comm -12 <(sort -u "$TMP/needles-refuse.txt") <(sort -u "$TMP/needles-report.txt") | grep . | head -1)" + if [ -n "$dup" ]; then cant_tell "$g" "'$FORBIDDEN' lists needle '$dup' in both [strings-refuse] and [strings-report] — a needle has one tier"; RAN=$((RAN + 1)); return; fi + # The committed refuse tier is judged BEFORE the private needles join it: a + # list whose only hard rules arrive from a secret is misconfigured. + n_refuse="$(grep -c . "$TMP/needles-refuse.txt" || true)" + if [ "$n_refuse" -eq 0 ]; then cant_tell "$g" "'$FORBIDDEN' has no [strings-refuse] entries — a guard with nothing to refuse is misconfigured"; RAN=$((RAN + 1)); return; fi + for extra in "${EXTRA_FORBIDDEN[@]+"${EXTRA_FORBIDDEN[@]}"}"; do + if [ ! -r "$extra" ]; then cant_tell "$g" "extra forbidden list '$extra' is missing or unreadable"; RAN=$((RAN + 1)); return; fi + if [ "$(read_list "$extra" "" | grep -c .)" -eq 0 ]; then cant_tell "$g" "extra forbidden list '$extra' is empty — the private needles were not supplied, so this scan cannot vouch for them"; RAN=$((RAN + 1)); return; fi + read_list "$extra" "" >>"$TMP/needles-private.txt" + done + : >>"$TMP/needles-private.txt" + n_refuse="$(( $(grep -c . "$TMP/needles-refuse.txt" || true) + $(grep -c . "$TMP/needles-private.txt" || true) ))" + n_report="$(grep -c . "$TMP/needles-report.txt" || true)" + n_allow="$(grep -c . "$TMP/allow.txt" || true)" + + # Census of what the scan can and cannot see. + local n_text=0 n_bin=0 f + while IFS= read -r f; do + if [ "$(tr -d -c '\000' <"$f" | wc -c | tr -d ' ')" -gt 0 ]; then n_bin=$((n_bin + 1)); else n_text=$((n_text + 1)); fi + done < <(find "$OUT/tree" "$OUT/assets" -type f 2>/dev/null) + if [ "$n_text" -eq 0 ]; then cant_tell "$g" "no text file staged — nothing this scan can read"; RAN=$((RAN + 1)); return; fi + + local -a scan_dirs=("$OUT/tree") + [ -d "$OUT/assets" ] && scan_dirs+=("$OUT/assets") + local allow_expr + allow_expr="$(paste -sd'|' "$TMP/allow.txt")" + # needle_hits NEEDLE SHOWN — write the `area/file:line` locations NEEDLE + # matches, after [allow] stripping, to $TMP/hits.txt. Returns 2 when grep + # itself failed, with the reason in $GREP_ERR; the caller reports + # could-not-tell. SHOWN is how the needle is named in any message: the + # pattern for a committed needle, `private needle #N` for one that came from + # --extra-forbidden — those are the identifiers kept out of the public list, + # and this log is public too. + needle_hits() { + local needle="$1" shown="$2" rc + # Hits go through a FILE, never `producer | grep -q`: a closed pipe would + # turn a real finding into "clean" via SIGPIPE. + grep -rIinE -e "$needle" "${scan_dirs[@]}" >"$TMP/hits.txt" 2>"$TMP/grep.err"; rc=$? + if [ "$rc" -ge 2 ]; then GREP_ERR="grep exited $rc on $shown: $(tr '\n' ' ' <"$TMP/grep.err")"; return 2; fi + if [ "$rc" -ne 0 ]; then : >"$TMP/hits.txt"; return 0; fi + # [allow] tokens are removed from each hit line and the needle re-tested, so + # a line is spared only when the allowed token was the whole reason it hit. + # A token is removed only as a WHOLE word — not when it is the tail of a + # longer mailbox (`devsupport@…`) or the head of a longer domain — and + # case-insensitively, as the scan itself matches. A sentence-ending `.` + # after the token is still a boundary. + # Split each hit into its location and its text; only the TEXT is re-tested, + # so the `file:line` prefix can never be what matches. + awk -F: '{ print $1 ":" $2 }' "$TMP/hits.txt" >"$TMP/locs.txt" + sed -E 's/^[^:]*:[^:]*://' "$TMP/hits.txt" >"$TMP/texts.txt" + if [ "$n_allow" -gt 0 ]; then + sed -E "s#(^|[^[:alnum:]._%+-])($allow_expr)($|[^[:alnum:]._%+-]|\.([^[:alnum:]]|$))#\1 \3#gI" "$TMP/texts.txt" >"$TMP/texts2.txt" && mv "$TMP/texts2.txt" "$TMP/texts.txt" + fi + grep -inE -e "$needle" "$TMP/texts.txt" | cut -d: -f1 >"$TMP/kept.txt"; rc=${PIPESTATUS[0]} + if [ "$rc" -ge 2 ]; then GREP_ERR="re-test after [allow] stripping exited $rc on $shown"; return 2; fi + awk 'NR == FNR { keep[$1] = 1; next } (FNR in keep)' "$TMP/kept.txt" "$TMP/locs.txt" | sed "s|^$OUT/||" >"$TMP/hits.txt" + return 0 + } + + # Three passes: the committed refuse tier, the private needles (refuse tier, + # named by number only), the report tier. + local tier label shown k n_refused=0 n_reported=0 + : >"$TMP/report-locs.txt" + for tier in refuse private report; do + k=0 + while IFS= read -r needle; do + k=$((k + 1)) + if [ "$tier" = private ]; then shown="private needle #$k"; else shown="needle '$needle'"; fi + needle_hits "$needle" "$shown" || { cant_tell "$g" "$GREP_ERR"; RAN=$((RAN + 1)); return; } + hits="$(grep -c . "$TMP/hits.txt" || true)" + [ "$hits" -gt 0 ] || continue + if [ "$tier" != report ]; then + { echo "[strings-refuse] $shown:"; cat "$TMP/hits.txt"; } >>"$REPORT" + n_refused=$((n_refused + hits)); label="strings-refuse" + else + { echo "[strings-report] $shown:"; cat "$TMP/hits.txt"; } >>"$REPORT" + n_reported=$((n_reported + hits)); cat "$TMP/hits.txt" >>"$TMP/report-locs.txt" + if [ "$STRICT" -eq 1 ]; then + label="strings-report (strict)" + else + note "$g" "[strings-report] $shown found in $hits staged line(s) — counted, not refused (--strict refuses)" + continue + fi + fi + refuse "$g" "[$label] $shown found in $hits staged line(s):" + head -20 "$TMP/hits.txt" | sed 's/^/ /' + [ "$hits" -le 20 ] || echo " … and $((hits - 20)) more (full list in publish-guard-report.txt)" + done <"$TMP/needles-$tier.txt" + done + if [ "$n_reported" -gt 0 ]; then + # Where the report tier lands, so the clean-up (or the decision not to) has + # a map: count per file, ten most-hit first. + sed 's/:[0-9]*$//' "$TMP/report-locs.txt" | sort | uniq -c | sort -rn >"$TMP/report-files.txt" + note "$g" "[strings-report] $n_reported hit(s) in $(grep -c . "$TMP/report-files.txt") file(s); most-hit files:" + head -10 "$TMP/report-files.txt" | awk '{ n = $1; sub(/^ *[0-9]+ /, ""); printf " %6d %s\n", n, $0 }' + fi + local tally="$n_refuse refuse + $n_report report needle(s), $n_allow allow token(s); $n_text text file(s) scanned, $n_bin binary file(s) opaque to this scan" + if [ "$n_refused" -eq 0 ] && [ "$n_reported" -eq 0 ]; then + note "$g" "clean ($tally)" + elif [ "$STRICT" -eq 1 ]; then + note "$g" "$n_refused refuse-tier hit(s), $n_reported report-tier hit(s) refused under --strict ($tally)" + else + note "$g" "$n_refused refuse-tier hit(s), $n_reported report-tier hit(s) counted ($tally)" + fi + RAN=$((RAN + 1)) +} + +# ---- guard 4: gitleaks ----------------------------------------------------------- +guard_gitleaks() { + local g="gitleaks" bin="${PUBLISH_GUARD_GITLEAKS:-gitleaks}" rc + if ! command -v "$bin" >/dev/null 2>&1; then cant_tell "$g" "scanner '$bin' is not on PATH — a scan that did not run is not a clean scan"; RAN=$((RAN + 1)); return; fi + # Leaks exit with a code no crash uses (default 1 is also "something broke"), + # so a scanner failure cannot be misread as either verdict. + "$bin" detect --no-git --redact --no-banner --exit-code 9 --source "$OUT" >"$TMP/gitleaks.out" 2>&1; rc=$? + case "$rc" in + 0) note "$g" "clean ($("$bin" version 2>/dev/null | head -1 || echo 'version unknown'), default rules, $(staged_paths | grep -c .) staged file(s))" ;; + 9) refuse "$g" "secrets detected in the staged tree:"; grep -vE '^[0-9]+:[0-9]+[AP]M' "$TMP/gitleaks.out" | sed 's/^/ /' | tee -a "$REPORT" ;; + *) cant_tell "$g" "scanner exited $rc: $(tail -3 "$TMP/gitleaks.out" | tr '\n' ' ')" ;; + esac + RAN=$((RAN + 1)) +} + +# ---- run everything, then judge --------------------------------------------------- +guard_allowlist +stage_assets +guard_forbidden_paths +guard_forbidden_strings +guard_gitleaks + +cp "$REPORT" "$OUT/publish-guard-report.txt" 2>/dev/null || true + +if [ "$RAN" -ne "$GUARDS_EXPECTED" ]; then + cant_tell "self-check" "$RAN of $GUARDS_EXPECTED guards reached a verdict" +fi +case "$WORST" in + 0) echo "publish-guard: OK — all $GUARDS_EXPECTED guards ran and passed; $OUT/tree is the deliverable." ;; + 1) echo "::error::publish-guard: REFUSED — do not publish $OUT (see the [guard] lines above)." ;; + *) echo "::error::publish-guard: COULD NOT TELL — do not publish $OUT (see the [guard] lines above)." ;; +esac +exit "$WORST" diff --git a/scripts/publish-mirror.sh b/scripts/publish-mirror.sh new file mode 100755 index 0000000..c15d6be --- /dev/null +++ b/scripts/publish-mirror.sh @@ -0,0 +1,199 @@ +#!/usr/bin/env bash +# ============================================================================= +# publish-mirror.sh — the publish half of the mirror pipeline: push what +# scripts/publish-guard.sh staged and cleared to the public mirror repository. +# +# Three subcommands, each one step of the workflow, each refusing on its own: +# +# target --mirror NAME --source-repo OWNER/REPO [--owner OWNER] +# [--output FILE] +# Validate the mirror name and print OWNER/NAME. Refuses an empty +# name (the mirror is unset until it exists — there is no default), +# a name with characters GitHub does not allow, and a target equal +# to the source repository: publishing onto the source would +# replace the default branch of the repo you are standing in. +# --output appends `repo=OWNER/NAME` and `name=NAME` to FILE (the +# workflow passes $GITHUB_OUTPUT). +# +# tree --stage DIR --repo OWNER/NAME --branch NAME --message TEXT +# [--remote URL] [--output FILE] +# Clone the mirror branch (or start it when the mirror has none), +# replace its content with DIR, commit, PLAIN push. A diverged +# remote rejects the push; nothing here ever forces. Prints +# `pushed ` or `unchanged `; --output appends +# `result=pushed|unchanged` and `sha=` to FILE. +# +# Results go to --output, refusals go to stdout: a caller that captured stdout +# with `$(...)` to read the result would swallow the `::error::` line of a +# refusal, so the workflow runs these commands directly and reads the file. +# Nothing is written to --output on a refusal. +# +# release --tag TAG --repo OWNER/NAME --target SHA --assets DIR +# --notes FILE [--prerelease] +# Create TAG on the mirror at SHA with every file in DIR attached. +# Refuses when TAG already exists on the mirror: a published release +# is never overwritten, and a re-run of a mirrored release is a +# human decision. +# +# Exit 0 done; 1 refused (the message says why); 2 could not tell (an input +# missing or unreadable, a remote that did not answer). "Cannot tell" never +# publishes. +# +# Authentication is the caller's: git reads its credential helper, `gh` reads +# GH_TOKEN. Nothing here takes a token argument, so no token can land on a +# command line. Commits are authored as PUBLISH_MIRROR_GIT_NAME / +# PUBLISH_MIRROR_GIT_EMAIL (default: github-actions[bot]). +# ============================================================================= +set -uo pipefail + +die1() { echo "::error::publish-mirror: REFUSED — $1"; exit 1; } +die2() { echo "::error::publish-mirror: COULD NOT TELL — $1 (never publishes)"; exit 2; } + +REPO_RE='^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$' +NAME_RE='^[A-Za-z0-9_.-]+$' + +# emit_output FILE KEY=VALUE... — append results for the caller (the workflow's +# $GITHUB_OUTPUT). An unwritable file is "could not tell": a result the caller +# never receives is a publish it cannot finish or account for. +emit_output() { + local file="$1"; shift + [ -n "$file" ] || return 0 + printf '%s\n' "$@" >>"$file" || die2 "could not write results to '$file'" +} + +cmd_target() { + local mirror="" source_repo="" owner="" output="" + while [ "$#" -gt 0 ]; do + case "$1" in + --mirror) mirror="${2:-}"; shift 2 ;; + --source-repo) source_repo="${2:-}"; shift 2 ;; + --owner) owner="${2:-}"; shift 2 ;; + --output) output="${2:-}"; shift 2 ;; + *) die2 "target: unknown argument '$1'" ;; + esac + done + [ -n "$source_repo" ] || die2 "target: --source-repo is required" + [[ "$source_repo" =~ $REPO_RE ]] || die2 "target: --source-repo '$source_repo' is not OWNER/REPO" + [ -n "$owner" ] || owner="${source_repo%%/*}" + [ -n "$mirror" ] || die1 "no mirror repository is configured (MIRROR_REPO is unset) — the mirror has no default, so nothing is published until one is named" + case "$mirror" in */*) die1 "mirror name '$mirror' must be a bare repository name in the '$owner' organisation, not OWNER/NAME" ;; esac + [[ "$mirror" =~ $NAME_RE ]] || die1 "mirror name '$mirror' contains characters a repository name cannot" + local full="$owner/$mirror" + if [ "$(printf '%s' "$full" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$source_repo" | tr '[:upper:]' '[:lower:]')" ]; then + die1 "mirror '$full' is this repository — publishing onto the source would replace its default branch" + fi + emit_output "$output" "repo=$full" "name=$mirror" + printf '%s\n' "$full" +} + +cmd_tree() { + local stage="" repo="" branch="" message="" remote="" output="" + while [ "$#" -gt 0 ]; do + case "$1" in + --stage) stage="${2:-}"; shift 2 ;; + --repo) repo="${2:-}"; shift 2 ;; + --branch) branch="${2:-}"; shift 2 ;; + --message) message="${2:-}"; shift 2 ;; + --remote) remote="${2:-}"; shift 2 ;; + --output) output="${2:-}"; shift 2 ;; + *) die2 "tree: unknown argument '$1'" ;; + esac + done + [ -n "$stage" ] && [ -n "$repo" ] && [ -n "$branch" ] && [ -n "$message" ] || die2 "tree: --stage, --repo, --branch and --message are all required" + [[ "$repo" =~ $REPO_RE ]] || die2 "tree: --repo '$repo' is not OWNER/NAME" + [[ "$branch" =~ ^[A-Za-z0-9_./-]+$ ]] || die2 "tree: --branch '$branch' is not a branch name" + [ -d "$stage" ] || die2 "tree: stage '$stage' is not a directory" + [ -n "$(find "$stage" -type f | head -1)" ] || die2 "tree: stage '$stage' holds no files — an empty deliverable is not published" + [ ! -e "$stage/.git" ] || die2 "tree: stage '$stage' contains a .git entry — that is a checkout, not a staged deliverable" + [ -n "$remote" ] || remote="https://github.com/$repo.git" + + local name="${PUBLISH_MIRROR_GIT_NAME:-github-actions[bot]}" + local email="${PUBLISH_MIRROR_GIT_EMAIL:-github-actions[bot]@users.noreply.github.com}" + # The checkout lives in its own subdirectory of the scratch dir; error + # captures live BESIDE it, never inside it, or they would be committed. + local scratch work + scratch="$(mktemp -d "${TMPDIR:-/tmp}/publish-mirror.XXXXXX")" && [ -d "$scratch" ] || die2 "tree: could not create a scratch directory" + trap 'rm -rf "$scratch"' EXIT + work="$scratch/work" + mkdir -p "$work" || die2 "tree: could not create the checkout directory" + + git -C "$work" init -q || die2 "tree: git init failed" + git -C "$work" remote add origin "$remote" || die2 "tree: could not add remote" + # Absent-vs-unreachable are different answers: ls-remote's own status says + # whether the remote answered; an empty answer says the branch is not there. + local heads rc existed=0 + heads="$(git -C "$work" ls-remote --heads origin "refs/heads/$branch" 2>"$scratch/lsr.err")"; rc=$? + [ "$rc" -eq 0 ] || die2 "tree: the mirror remote did not answer (git ls-remote exited $rc: $(tr '\n' ' ' <"$scratch/lsr.err"))" + if [ -n "$heads" ]; then + existed=1 + git -C "$work" fetch -q --depth 1 origin "refs/heads/$branch" || die2 "tree: could not fetch '$branch' from the mirror" + git -C "$work" checkout -q -B "$branch" FETCH_HEAD || die2 "tree: could not check out '$branch'" + find "$work" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} + || die2 "tree: could not clear the checkout" + else + git -C "$work" checkout -q --orphan "$branch" || die2 "tree: could not start branch '$branch'" + fi + cp -Rp "$stage"/. "$work"/ || die2 "tree: could not copy the stage into the checkout" + git -C "$work" add -A || die2 "tree: git add failed" + local sha + if [ "$existed" -eq 1 ] && git -C "$work" diff --cached --quiet; then + sha="$(git -C "$work" rev-parse HEAD)" + emit_output "$output" "result=unchanged" "sha=$sha" + echo "unchanged $sha" + return 0 + fi + git -C "$work" -c user.name="$name" -c user.email="$email" commit -q -m "$message" || die2 "tree: git commit failed" + # A PLAIN push. If the mirror moved underneath us the push is rejected and + # this exits 2; the answer is to re-run, never to force. + git -C "$work" push -q origin "HEAD:refs/heads/$branch" 2>"$scratch/push.err" || die2 "tree: push to '$repo' '$branch' was rejected: $(tr '\n' ' ' <"$scratch/push.err")" + sha="$(git -C "$work" rev-parse HEAD)" + emit_output "$output" "result=pushed" "sha=$sha" + echo "pushed $sha" +} + +cmd_release() { + local tag="" repo="" target="" assets="" notes="" prerelease=0 + while [ "$#" -gt 0 ]; do + case "$1" in + --tag) tag="${2:-}"; shift 2 ;; + --repo) repo="${2:-}"; shift 2 ;; + --target) target="${2:-}"; shift 2 ;; + --assets) assets="${2:-}"; shift 2 ;; + --notes) notes="${2:-}"; shift 2 ;; + --prerelease) prerelease=1; shift ;; + *) die2 "release: unknown argument '$1'" ;; + esac + done + [ -n "$tag" ] && [ -n "$repo" ] && [ -n "$target" ] && [ -n "$assets" ] && [ -n "$notes" ] || die2 "release: --tag, --repo, --target, --assets and --notes are all required" + [[ "$repo" =~ $REPO_RE ]] || die2 "release: --repo '$repo' is not OWNER/NAME" + [[ "$tag" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || die1 "release: '$tag' is not a release tag (vX.Y.Z or vX.Y.Z-
)"
+  [[ "$target" =~ ^[0-9a-f]{40}$ ]] || die2 "release: --target '$target' is not a full commit sha"
+  [ -d "$assets" ] || die2 "release: assets '$assets' is not a directory"
+  [ -s "$notes" ] || die2 "release: notes file '$notes' is missing or empty"
+  local -a files=()
+  while IFS= read -r f; do files+=("$f"); done < <(find "$assets" -mindepth 1 -maxdepth 1 -type f | sort)
+  [ "${#files[@]}" -gt 0 ] || die2 "release: '$assets' holds no files — a release with no assets is not what a customer downloads"
+  command -v gh >/dev/null 2>&1 || die2 "release: gh is not on PATH"
+
+  # Existing release → refuse. `gh release view` exits 1 for "not found" AND for
+  # auth or network failure, so the text decides which it was; anything that is
+  # not a clear "not found" is "cannot tell".
+  local err rc
+  err="$(gh release view "$tag" --repo "$repo" 2>&1 >/dev/null)"; rc=$?
+  if [ "$rc" -eq 0 ]; then die1 "release: '$tag' already exists on '$repo' — a mirrored release is never overwritten"; fi
+  printf '%s' "$err" | grep -qi 'release not found' || die2 "release: could not read releases of '$repo' (gh exited $rc: $(printf '%s' "$err" | tr '\n' ' '))"
+
+  local -a args=(release create "$tag" --repo "$repo" --target "$target" --title "$tag" --notes-file "$notes")
+  [ "$prerelease" -eq 1 ] && args+=(--prerelease)
+  gh "${args[@]}" "${files[@]}" || die2 "release: gh release create exited $?"
+  echo "released $tag on $repo at $target with ${#files[@]} asset(s)"
+}
+
+[ "$#" -ge 1 ] || die2 "a subcommand is required: target | tree | release"
+sub="$1"; shift
+case "$sub" in
+  target)  cmd_target "$@" ;;
+  tree)    cmd_tree "$@" ;;
+  release) cmd_release "$@" ;;
+  -h|--help) sed -n '2,/^# ====/p' "$0" | sed 's/^# \{0,2\}//' ;;
+  *) die2 "unknown subcommand '$sub' (target | tree | release)" ;;
+esac
diff --git a/scripts/tests/mirror-publish-workflow-verify.sh b/scripts/tests/mirror-publish-workflow-verify.sh
new file mode 100644
index 0000000..63f6741
--- /dev/null
+++ b/scripts/tests/mirror-publish-workflow-verify.sh
@@ -0,0 +1,435 @@
+#!/usr/bin/env bash
+# =============================================================================
+#  mirror-publish-workflow-verify.sh — pin the decisions
+#  .github/workflows/mirror-publish.yml takes ITSELF, in step bodies no script
+#  owns: what to publish (plan), that the release tag is fetched as data and
+#  only at the expected commit (src), that a prerelease keeps the mirror's
+#  default branch (keep), and that a publisher refusal reaches the step log
+#  (target).
+#
+#  THE CODE UNDER TEST IS THE WORKFLOW. Each step's `run:` body is read out of
+#  the YAML and executed under bash with the step's env set and `gh` shimmed —
+#  the same text Actions runs, not a copy of it. The gh shim answers
+#  `release view` and `api` from env; the tag fetch runs against a real bare
+#  repository over file://.
+#
+#  Pinned, and the review finding each answers:
+#    * a prerelease sets publish_tree=false and says why; the newest stable
+#      release sets it true — and every step that pushes a tree is gated on
+#      that output, the release step is not ("prerelease overwrites public
+#      default branch")
+#    * a stable tag that is NOT the newest stable release (a Release re-run, a
+#      dispatch of an old tag) sets publish_tree=false too, and a run that
+#      cannot read the newest release refuses; disarming the comparison is a
+#      mutation the older-tag case catches ("older stable tags replace mirror
+#      docs")
+#    * isPrerelease must be an explicit boolean: a release whose isPrerelease
+#      is missing or malformed (`null`) is refused, never read as "stable";
+#      accepting any value is a mutation the null case catches ("a null
+#      isPrerelease fails open into the tree push")
+#    * a guard refusal reaches $GITHUB_STEP_SUMMARY and the step exits with the
+#      guard's status: the body runs under Actions' `bash -e`, so the guard's
+#      exit is caught with `|| rc=$?`; dropping that is a mutation the guard
+#      case catches ("errexit skips the summary on refusal")
+#    * no actions/checkout step takes a `ref:` — the tooling runs from this
+#      workflow's own commit; the release tag is fetched into a detached
+#      worktree and refused unless it resolves to the commit the plan step
+#      expects ("checkout of untrusted code in a privileged context")
+#    * a refusal from publish-mirror.sh is a `::error::` line in the step's
+#      stdout, so no step captures the publisher through `$(...)` ("captured
+#      output hides publish refusals")
+#
+#  FAILS CLOSED: an unreadable workflow, a missing step id, or PyYAML absent is
+#  a named refusal (exit 2), never "nothing to check". The shape check is one
+#  function run over the real workflow AND over mutated copies, each mutation
+#  asserted to have changed the document before it is judged.
+# =============================================================================
+set -uo pipefail
+
+SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO_ROOT="$(cd "$SELF_DIR/../.." && pwd)"
+WF="$REPO_ROOT/.github/workflows/mirror-publish.yml"
+[ -f "$WF" ] || { printf 'mirror-publish-workflow-verify: %s missing — refusing to report clean\n' "$WF" >&2; exit 2; }
+command -v python3 >/dev/null 2>&1 || { echo 'mirror-publish-workflow-verify: python3 missing — refusing to report clean' >&2; exit 2; }
+python3 -c 'import yaml' 2>/dev/null || { echo '[ERROR] PyYAML required (pip install pyyaml) — refusing to report clean' >&2; exit 2; }
+
+PASS=0
+FAIL=0
+ok()  { printf '  ok   %s\n' "$1"; PASS=$((PASS+1)); }
+bad() { printf '  FAIL %s\n' "$1"; FAIL=$((FAIL+1)); }
+
+ROOT="$(mktemp -d "${TMPDIR:-/tmp}/mirror-publish-workflow-verify.XXXXXX")"
+trap 'rm -rf "$ROOT"' EXIT
+SHIM="$ROOT/shim"; WORK="$ROOT/work"; mkdir -p "$SHIM" "$WORK" "$ROOT/runner-temp"
+# gh shim: `release view` prints GH_RELEASE_JSON (or fails with GH_RELEASE_RC);
+# `api .../releases/latest` prints GH_LATEST_TAG (or fails with GH_LATEST_RC);
+# any other `api ... --jq .sha` prints GH_API_SHA (or fails with GH_API_RC).
+# Every call is logged so a case can assert WHICH question the step asked.
+cat >"$SHIM/gh" <<'EOF'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >>"${GH_LOG:?}"
+case "${1:-} ${2:-}" in
+  "release view")
+    [ "${GH_RELEASE_RC:-0}" -eq 0 ] || { echo "release not found" >&2; exit "$GH_RELEASE_RC"; }
+    printf '%s\n' "${GH_RELEASE_JSON:?}" ;;
+  "api "*)
+    case "${2:-}" in
+      *releases/latest)
+        [ "${GH_LATEST_RC:-0}" -eq 0 ] || { echo "HTTP 404: Not Found" >&2; exit "$GH_LATEST_RC"; }
+        printf '%s\n' "${GH_LATEST_TAG:?}" ;;
+      *)
+        [ "${GH_API_RC:-0}" -eq 0 ] || { echo "HTTP 409: Git Repository is empty" >&2; exit "$GH_API_RC"; }
+        printf '%s\n' "${GH_API_SHA:?}" ;;
+    esac ;;
+esac
+exit 0
+EOF
+chmod +x "$SHIM/gh"
+export GH_LOG="$ROOT/gh.log"
+export GITHUB_OUTPUT="$ROOT/github-output"
+export RUNNER_TEMP="$ROOT/runner-temp"
+export GITHUB_REPOSITORY="example/source"
+export GITHUB_WORKSPACE="$REPO_ROOT"
+SHA_A=1111111111111111111111111111111111111111
+SHA_B=2222222222222222222222222222222222222222
+
+# reset_env — the plan step's job-level env, every field set (the body runs
+# under set -u); a fresh GITHUB_OUTPUT and gh log per case.
+reset_env() {
+  export EVENT_NAME=workflow_run INPUT_TAG="" INPUT_DRY_RUN="" INPUT_MIRROR="" INPUT_STRICT=""
+  export RUN_HEAD_BRANCH="" RUN_HEAD_SHA="" VAR_MIRROR="" VAR_STRICT=""
+  export TAG="" EXPECT_SHA="" BRANCH="" REPO=""
+  unset GH_RELEASE_JSON GH_RELEASE_RC GH_API_SHA GH_API_RC GH_LATEST_TAG GH_LATEST_RC
+  : >"$GITHUB_OUTPUT"; : >"$GH_LOG"
+  rm -rf "$RUNNER_TEMP"; mkdir -p "$RUNNER_TEMP"
+}
+
+# step_run   — print that step's `run:` body; refuse when absent.
+step_run() {
+  python3 - "$1" "$2" <<'PY'
+import sys
+try:
+    import yaml
+except ImportError:
+    sys.exit("[ERROR] PyYAML required (pip install pyyaml)")
+path, want = sys.argv[1], sys.argv[2]
+try:
+    with open(path) as fh:
+        doc = yaml.safe_load(fh)
+except (OSError, yaml.YAMLError) as e:
+    sys.exit("FAIL: cannot read or parse workflow %s: %s" % (path, e))
+steps = ((doc.get("jobs") or {}).get("publish") or {}).get("steps") or []
+for s in steps:
+    if isinstance(s, dict) and s.get("id") == want:
+        if "run" not in s:
+            sys.exit("FAIL: step %r has no run: body" % want)
+        sys.stdout.write(s["run"])
+        sys.exit(0)
+sys.exit("FAIL: no step with id %r in %s" % (want, path))
+PY
+}
+
+# run_step_in   [cwd] — execute the step body as Actions
+# would: its own bash, the exported env, the gh shim first on PATH. Sets OUTPUT
+# and RC. run_step runs the real workflow; run_step_in a mutated copy of it.
+# `bash -e`: what Actions runs a `run:` body with. A body that relies on
+# surviving a failing command (the guard step's tee pipeline) is tested under
+# the same errexit it gets in CI, or the test proves nothing about the step.
+run_step_in() {
+  local body="$ROOT/step-$2.sh"
+  if ! step_run "$1" "$2" >"$body"; then OUTPUT="$(cat "$body")"; RC=2; return; fi
+  local dir="${3:-$WORK}"
+  OUTPUT="$(PATH="$SHIM:$PATH" bash -c "cd '$dir' && bash -e '$body'" 2>&1)"; RC=$?
+}
+run_step() { run_step_in "$WF" "$@"; }
+out() { grep -E "^$1=" "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2-; }
+has() { [[ "$OUTPUT" == *"$1"* ]]; }
+release_json() { printf '{"tagName":"%s","isDraft":false,"isPrerelease":%s}' "$1" "$2"; }
+
+echo "== mirror-publish.yml step bodies =="
+
+# ---- plan ---------------------------------------------------------------------------
+reset_env; export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_LATEST_TAG=v1.2.3; GH_RELEASE_JSON="$(release_json v1.2.3 false)"; export GH_RELEASE_JSON
+run_step plan
+if [ "$RC" -eq 0 ] && [ "$(out tag)" = v1.2.3 ] && [ "$(out dry_run)" = false ] && [ "$(out prerelease)" = false ] && [ "$(out publish_tree)" = true ] && [ "$(out expect_sha)" = "$SHA_A" ] \
+   && grep -q '^release view v1.2.3 --repo example/source --json tagName,isDraft,isPrerelease$' "$GH_LOG" && grep -q '^api repos/example/source/releases/latest --jq .tag_name$' "$GH_LOG" && ! has "::notice::"; then
+  ok "plan: the newest stable release from workflow_run publishes tree and release, pinned to head_sha"
+else bad "plan stable (rc=$RC): $OUTPUT / $(cat "$GITHUB_OUTPUT")"; fi
+
+# An older stable tag (a Release re-run, or a dispatch of it) must not roll the
+# mirror's default branch back to its tree; the release alone is mirrored.
+reset_env; export RUN_HEAD_BRANCH=v1.2.2 RUN_HEAD_SHA="$SHA_A" GH_LATEST_TAG=v1.2.3; GH_RELEASE_JSON="$(release_json v1.2.2 false)"; export GH_RELEASE_JSON
+run_step plan
+if [ "$RC" -eq 0 ] && [ "$(out tag)" = v1.2.2 ] && [ "$(out prerelease)" = false ] && [ "$(out publish_tree)" = false ] && [ "$(out expect_sha)" = "$SHA_A" ] \
+   && has "::notice::'v1.2.2' is not the newest stable release (v1.2.3 is): only its GitHub release is mirrored. The mirror's default branch is not pushed"; then
+  ok "plan: an older stable release mirrors only its release — publish_tree=false, and the log says why"
+else bad "plan older stable (rc=$RC): $OUTPUT / $(cat "$GITHUB_OUTPUT")"; fi
+
+reset_env; export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_LATEST_RC=1; GH_RELEASE_JSON="$(release_json v1.2.3 false)"; export GH_RELEASE_JSON
+run_step plan
+if [ "$RC" -eq 1 ] && has "::error::cannot determine the newest stable release of example/source — refusing to decide whether 'v1.2.3' may replace the mirror's default branch." && [ ! -s "$GITHUB_OUTPUT" ]; then
+  ok "plan: when the newest stable release cannot be read, a stable tag is refused rather than guessed newest"
+else bad "plan latest unreadable (rc=$RC): $OUTPUT / $(cat "$GITHUB_OUTPUT")"; fi
+
+reset_env; export RUN_HEAD_BRANCH=v1.2.3-rc.1 RUN_HEAD_SHA="$SHA_A"; GH_RELEASE_JSON="$(release_json v1.2.3-rc.1 true)"; export GH_RELEASE_JSON
+run_step plan
+if [ "$RC" -eq 0 ] && [ "$(out prerelease)" = true ] && [ "$(out publish_tree)" = false ] && [ "$(out expect_sha)" = "$SHA_A" ] && ! grep -q 'releases/latest' "$GH_LOG" \
+   && has "::notice::'v1.2.3-rc.1' is a prerelease: only its GitHub release is mirrored (marked prerelease). The mirror's default branch is not pushed"; then
+  ok "plan: a prerelease mirrors only its release — publish_tree=false, the newest stable release is not consulted, and the log says why"
+else bad "plan prerelease (rc=$RC): $OUTPUT / $(cat "$GITHUB_OUTPUT")"; fi
+
+# A release object without isPrerelease (`jq -r` prints `null`) must not fall
+# through into the stable path: only an explicit false arms the tree push.
+reset_env; export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_LATEST_TAG=v1.2.3 GH_RELEASE_JSON='{"tagName":"v1.2.3","isDraft":false}'
+run_step plan
+if [ "$RC" -eq 1 ] && has "::error::release 'v1.2.3' reports isPrerelease 'null' — not a boolean, refusing: only an explicit false may replace the mirror's default branch." && [ ! -s "$GITHUB_OUTPUT" ] && ! grep -q 'releases/latest' "$GH_LOG"; then
+  ok "plan: a release whose isPrerelease is not a boolean is refused before the newest-release question is asked"
+else bad "plan isPrerelease null (rc=$RC): $OUTPUT / $(cat "$GITHUB_OUTPUT")"; fi
+
+reset_env; export RUN_HEAD_BRANCH=develop RUN_HEAD_SHA="$SHA_A"
+run_step plan
+if [ "$RC" -eq 1 ] && has "::error::'develop' is not a release tag" && [ ! -s "$GITHUB_OUTPUT" ] && [ ! -s "$GH_LOG" ]; then ok "plan: a workflow_run whose head is a branch is refused before anything is read"; else bad "plan branch head (rc=$RC): $OUTPUT"; fi
+
+reset_env; export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A"; GH_RELEASE_JSON="$(release_json v9.9.9 false)"; export GH_RELEASE_JSON
+run_step plan
+if [ "$RC" -eq 1 ] && has "::error::release 'v1.2.3' reports tag_name 'v9.9.9' — the tag and the release disagree, refusing." && [ ! -s "$GITHUB_OUTPUT" ]; then ok "plan: a release whose tag_name is not the run's tag is refused"; else bad "plan tag mismatch (rc=$RC): $OUTPUT"; fi
+
+reset_env; export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA=abc123; GH_RELEASE_JSON="$(release_json v1.2.3 false)"; export GH_RELEASE_JSON
+run_step plan
+if [ "$RC" -eq 1 ] && has "::error::cannot determine the commit release 'v1.2.3' was cut from (got 'abc123')" && [ ! -s "$GITHUB_OUTPUT" ]; then ok "plan: a workflow_run without a full head_sha to pin the tag to is refused"; else bad "plan bad head_sha (rc=$RC): $OUTPUT"; fi
+
+reset_env; export EVENT_NAME=workflow_dispatch INPUT_TAG=v1.2.3 INPUT_DRY_RUN=true GH_API_SHA="$SHA_B" GH_LATEST_TAG=v1.2.3; GH_RELEASE_JSON="$(release_json v1.2.3 false)"; export GH_RELEASE_JSON
+run_step plan; a="$RC"; dry="$(out dry_run)"; exp="$(out expect_sha)"; asked=0; grep -q '^api repos/example/source/commits/v1.2.3 --jq .sha$' "$GH_LOG" && asked=1
+: >"$GITHUB_OUTPUT"; export GH_API_RC=1; run_step plan
+if [ "$a" -eq 0 ] && [ "$dry" = true ] && [ "$exp" = "$SHA_B" ] && [ "$asked" -eq 1 ] && [ "$RC" -eq 1 ] && has "::error::cannot determine the commit release 'v1.2.3' was cut from (got '')"; then
+  ok "plan: a dispatch takes the expected commit from the API, stays a dry run unless told 'false', and refuses when the API does not answer"
+else bad "plan dispatch (a=$a dry=$dry exp=$exp asked=$asked rc=$RC): $OUTPUT"; fi
+
+# ---- src: the release tag is data, fetched only at the expected commit -----------------
+make_origin() { # a bare origin with one commit tagged v1.2.3 (annotated); WORK becomes its clone; prints the commit
+  local seed="$ROOT/seed" bare="$ROOT/origin.git"
+  rm -rf "$seed" "$bare" "$WORK"
+  git init -q --bare "$bare"
+  # The bare HEAD is pinned to `main` explicitly: with init.defaultBranch unset
+  # (a fresh runner) it would point at a `master` that never receives a push,
+  # the clone would have an unborn HEAD, and `rev-parse HEAD` would print the
+  # literal word HEAD as the expected sha (measured on the first CI run).
+  git -C "$bare" symbolic-ref HEAD refs/heads/main
+  git init -q "$seed"
+  printf 'readme\n' >"$seed/README.md"
+  git -C "$seed" -c user.name=t -c user.email=t@example.invalid add README.md
+  git -C "$seed" -c user.name=t -c user.email=t@example.invalid commit -q -m one
+  git -C "$seed" -c user.name=t -c user.email=t@example.invalid tag -a v1.2.3 -m v1.2.3
+  git -C "$seed" push -q "file://$bare" HEAD:refs/heads/main refs/tags/v1.2.3
+  git clone -q "file://$bare" "$WORK" 2>/dev/null
+  git -C "$seed" rev-parse --verify HEAD
+}
+
+reset_env; sha="$(make_origin)"; export TAG=v1.2.3 EXPECT_SHA="$sha"
+run_step src
+if [ "$RC" -eq 0 ] && [ "$(out dir)" = "$RUNNER_TEMP/release-src" ] && [ "$(git -C "$RUNNER_TEMP/release-src" rev-parse HEAD 2>/dev/null)" = "$sha" ] && [ -f "$RUNNER_TEMP/release-src/README.md" ] && has "release source: v1.2.3 at $sha (data only)"; then
+  ok "src: the tag is fetched into a detached worktree outside the checkout, only at the expected commit"
+else bad "src fetch (rc=$RC): $OUTPUT"; fi
+
+reset_env; make_origin >/dev/null; export TAG=v1.2.3 EXPECT_SHA="$SHA_B"
+run_step src
+if [ "$RC" -eq 1 ] && has "::error::tag 'v1.2.3' resolves to " && has " but the release was cut at $SHA_B — the tag has moved or the run is not this release's; refusing." && [ ! -e "$RUNNER_TEMP/release-src" ] && [ ! -s "$GITHUB_OUTPUT" ]; then
+  ok "src: a tag that does not resolve to the expected commit is refused and nothing is checked out"
+else bad "src sha mismatch (rc=$RC): $OUTPUT"; fi
+
+reset_env; make_origin >/dev/null; export TAG=v9.9.9 EXPECT_SHA="$SHA_A"
+run_step src
+if [ "$RC" -eq 1 ] && has "::error::could not fetch tag 'v9.9.9' from origin" && [ ! -e "$RUNNER_TEMP/release-src" ]; then ok "src: a tag origin does not have is refused"; else bad "src missing tag (rc=$RC): $OUTPUT"; fi
+
+# ---- target / keep: refusals annotate, results go to GITHUB_OUTPUT -------------------
+reset_env; export VAR_MIRROR="" INPUT_MIRROR=""
+run_step target "$REPO_ROOT"
+if [ "$RC" -eq 1 ] && has "::error::publish-mirror: REFUSED — no mirror repository is configured (MIRROR_REPO is unset)" && [ ! -s "$GITHUB_OUTPUT" ]; then ok "target: an unset MIRROR_REPO is refused with the ::error:: line IN THE STEP LOG, nothing captured"; else bad "target unset (rc=$RC): $OUTPUT"; fi
+
+reset_env; export VAR_MIRROR=source-public INPUT_MIRROR=""
+run_step target "$REPO_ROOT"
+if [ "$RC" -eq 0 ] && [ "$(out repo)" = example/source-public ] && [ "$(out name)" = source-public ]; then ok "target: a configured mirror lands in GITHUB_OUTPUT as repo= and name="; else bad "target set (rc=$RC): $OUTPUT / $(cat "$GITHUB_OUTPUT")"; fi
+
+reset_env; export REPO=example/source-public BRANCH=main TAG=v1.2.3-rc.1 GH_API_SHA="$SHA_B"
+run_step keep; a="$RC"; got="$(out sha)"; asked=0; grep -q '^api repos/example/source-public/commits/main --jq .sha$' "$GH_LOG" && asked=1; o1="$OUTPUT"
+: >"$GITHUB_OUTPUT"; export GH_API_RC=1; run_step keep
+if [ "$a" -eq 0 ] && [ "$got" = "$SHA_B" ] && [ "$asked" -eq 1 ] && [[ "$o1" == *"release v1.2.3-rc.1: default branch 'main' left untouched"* ]] \
+   && [ "$RC" -eq 1 ] && has "::error::'v1.2.3-rc.1' does not replace the mirror's default branch (a prerelease, or not the newest stable release) and the mirror has no commit on 'main' to pin it to — it cannot be the first publish to an empty mirror" && [ ! -s "$GITHUB_OUTPUT" ]; then
+  ok "keep: a release that does not push the tree is pinned to the mirror's default-branch head; an empty mirror is refused"
+else bad "keep (a=$a got=$got asked=$asked rc=$RC): $o1 / $OUTPUT"; fi
+
+# ---- guard-tree: a refusal reaches the step summary, the step exits with it -----------
+# fake_guard  — a cwd holding a scripts/publish-guard.sh that refuses
+# (prints a guard line, exits 1) whatever it is asked; the guard itself has its
+# own suite, this is about what the STEP does with a refusal under errexit.
+fake_guard() {
+  rm -rf "$1"; mkdir -p "$1/scripts"
+  cat >"$1/scripts/publish-guard.sh" <<'EOF'
+#!/usr/bin/env bash
+echo "::error::publish-guard: [forbidden-strings] REFUSED — planted refusal"
+exit 1
+EOF
+}
+
+reset_env; fake_guard "$WORK"; export GITHUB_STEP_SUMMARY="$ROOT/summary.md" STRICT="" SRC_DIR=""; : >"$GITHUB_STEP_SUMMARY"
+run_step guard-tree
+if [ "$RC" -eq 1 ] && has "REFUSED — planted refusal" && grep -q '^## Mirror publish — tree$' "$GITHUB_STEP_SUMMARY" && grep -q 'REFUSED — planted refusal' "$GITHUB_STEP_SUMMARY"; then
+  ok "guard-tree: a guard refusal is written to the step summary and the step exits with the guard's status"
+else bad "guard-tree refusal (rc=$RC): $OUTPUT / summary: $(cat "$GITHUB_STEP_SUMMARY")"; fi
+unset GITHUB_STEP_SUMMARY
+
+# ---- shape: derived from the workflow, one implementation for real and mutated ---------
+# shape  — OK lines / one FAIL line. Every rule is derived from the
+# steps themselves (which steps check out, which invoke the publisher), never
+# from a list of step names held here.
+shape() {
+  OUTPUT="$(python3 - "$1" <<'PY' 2>&1
+import re, sys
+try:
+    import yaml
+except ImportError:
+    sys.exit("[ERROR] PyYAML required (pip install pyyaml)")
+
+path = sys.argv[1]
+
+
+def fail(msg):
+    print("FAIL: " + msg)
+    sys.exit(1)
+
+
+try:
+    with open(path) as fh:
+        doc = yaml.safe_load(fh)
+except (OSError, yaml.YAMLError) as e:
+    fail("cannot read or parse workflow %s: %s" % (path, e))
+publish = ((doc or {}).get("jobs") or {}).get("publish")
+if not isinstance(publish, dict):
+    fail("no `publish` job in %s" % path)
+steps = [s for s in (publish.get("steps") or []) if isinstance(s, dict)]
+if not steps:
+    fail("`publish` has no steps")
+
+GATE = "steps.plan.outputs.publish_tree == 'true'"
+
+checkouts = [s for s in steps if str(s.get("uses", "")).startswith("actions/checkout")]
+if not checkouts:
+    fail("no actions/checkout step — the tooling has to come from somewhere")
+for s in checkouts:
+    with_ = s.get("with") or {}
+    if "ref" in with_:
+        fail("checkout step %r takes a ref (%r): the tooling must come from this workflow's own commit, the release tag is data" % (s.get("name"), with_["ref"]))
+print("OK: %d checkout step(s), none with a ref" % len(checkouts))
+
+tree_pushes = [s for s in steps if re.search(r"publish-mirror\.sh\s+tree\b", str(s.get("run", "")))]
+if not tree_pushes:
+    fail("no step invokes `publish-mirror.sh tree` — nothing to gate")
+for s in tree_pushes:
+    if GATE not in str(s.get("if", "")):
+        fail("step %r pushes a tree without `if: ... %s` — a prerelease would replace the mirror's branch" % (s.get("name"), GATE))
+print("OK: %d tree push step(s), each gated on publish_tree" % len(tree_pushes))
+
+releases = [s for s in steps if re.search(r"publish-mirror\.sh\s+\"?\$\{?args|publish-mirror\.sh\s+release\b", str(s.get("run", "")))]
+if len(releases) != 1:
+    fail("expected exactly one release step, found %d" % len(releases))
+if "publish_tree" in str(releases[0].get("if", "")):
+    fail("the release step is gated on publish_tree — a prerelease must still get its release")
+print("OK: the release step is not gated on publish_tree")
+
+captured = [s for s in steps if re.search(r"\$\(\s*bash\s+scripts/publish-mirror\.sh", str(s.get("run", "")))]
+if captured:
+    fail("step %r captures publish-mirror.sh through $(...) — a refusal's ::error:: line would never reach the log" % captured[0].get("name"))
+print("OK: no step captures the publisher's output")
+
+fetches = [s for s in steps if re.search(r"git fetch[^\n]*refs/tags/", str(s.get("run", "")))]
+if len(fetches) != 1:
+    fail("expected exactly one step fetching a tag, found %d" % len(fetches))
+if "EXPECT_SHA" not in str(fetches[0].get("run", "")):
+    fail("the tag fetch step does not compare against EXPECT_SHA")
+print("OK: the one tag fetch compares against the expected commit")
+PY
+)"; RC=$?
+}
+
+# mutate  — write a mutated copy of the real
+# workflow and print its path. Applied to the PARSED document and asserted to
+# have changed it, so an inert edit cannot pass as coverage.
+mutate() {
+  local out="$ROOT/mutated-$RANDOM.yml"
+  python3 - "$WF" "$out" "$1" <<'PY' || return 1
+import copy, sys
+try:
+    import yaml
+except ImportError:
+    sys.exit("[ERROR] PyYAML required (pip install pyyaml)")
+src, dst, expr = sys.argv[1], sys.argv[2], sys.argv[3]
+with open(src) as fh:
+    doc = yaml.safe_load(fh)
+before = copy.deepcopy(doc)
+steps = doc["jobs"]["publish"]["steps"]
+exec(expr, {"doc": doc, "steps": steps})
+if doc == before:
+    sys.exit("mutation did not change the document: " + expr)
+with open(dst, "w") as fh:
+    yaml.safe_dump(doc, fh, sort_keys=False)
+print(dst)
+PY
+}
+
+shape "$WF"
+if [ "$RC" -eq 0 ] && has "OK: 1 checkout step(s), none with a ref" && has "OK: 1 tree push step(s), each gated on publish_tree" && has "OK: the release step is not gated on publish_tree" \
+   && has "OK: no step captures the publisher's output" && has "OK: the one tag fetch compares against the expected commit"; then
+  ok "shape: no checkout ref, tree push gated, release ungated, nothing captured, one pinned tag fetch"
+else bad "shape real (rc=$RC): $OUTPUT"; fi
+
+if m="$(mutate "[s for s in steps if str(s.get('uses','')).startswith('actions/checkout')][0]['with'] = {'ref': '\${{ steps.plan.outputs.tag }}'}")"; then
+  shape "$m"
+  if [ "$RC" -eq 1 ] && has "FAIL: checkout step " && has "takes a ref"; then ok "shape mutation: a checkout that takes a ref reddens"; else bad "shape mutation checkout ref (rc=$RC): $OUTPUT"; fi
+else bad "shape mutation checkout ref: mutation did not apply: $m"; fi
+
+if m="$(mutate "s = [s for s in steps if s.get('id') == 'push'][0]; s['if'] = \"steps.plan.outputs.dry_run != 'true'\"")"; then
+  shape "$m"
+  if [ "$RC" -eq 1 ] && has "FAIL: step " && has "pushes a tree without"; then ok "shape mutation: a tree push without the publish_tree gate reddens"; else bad "shape mutation ungated push (rc=$RC): $OUTPUT"; fi
+else bad "shape mutation ungated push: mutation did not apply: $m"; fi
+
+if m="$(mutate "s = [s for s in steps if s.get('id') == 'target'][0]; s['run'] = 'REPO=\"\$(bash scripts/publish-mirror.sh target --mirror x --source-repo a/b)\"\n'")"; then
+  shape "$m"
+  if [ "$RC" -eq 1 ] && has "FAIL: step " && has "captures publish-mirror.sh through"; then ok "shape mutation: capturing the publisher through \$(...) reddens"; else bad "shape mutation capture (rc=$RC): $OUTPUT"; fi
+else bad "shape mutation capture: mutation did not apply: $m"; fi
+
+if m="$(mutate "s = [s for s in steps if s.get('id') == 'src'][0]; s['run'] = s['run'].replace('EXPECT_SHA', 'IGNORED')")"; then
+  shape "$m"
+  if [ "$RC" -eq 1 ] && has "FAIL: the tag fetch step does not compare against EXPECT_SHA"; then ok "shape mutation: a tag fetch that skips the EXPECT_SHA comparison reddens"; else bad "shape mutation unpinned fetch (rc=$RC): $OUTPUT"; fi
+else bad "shape mutation unpinned fetch: mutation did not apply: $m"; fi
+
+# ---- plan body mutation: the newest-release comparison is what the older-tag case tests --
+# With the comparison disarmed the older tag WOULD set publish_tree=true, so the
+# older-stable case above is the assertion that catches a workflow without it.
+if m="$(mutate "s = [s for s in steps if s.get('id') == 'plan'][0]; s['run'] = s['run'].replace('[ \"\$LATEST_TAG\" != \"\$TAG\" ]', '[ \"\$LATEST_TAG\" != \"\$LATEST_TAG\" ]')")"; then
+  reset_env; export RUN_HEAD_BRANCH=v1.2.2 RUN_HEAD_SHA="$SHA_A" GH_LATEST_TAG=v1.2.3; GH_RELEASE_JSON="$(release_json v1.2.2 false)"; export GH_RELEASE_JSON
+  run_step_in "$m" plan
+  if [ "$RC" -eq 0 ] && [ "$(out publish_tree)" = true ] && ! has "is not the newest stable release"; then ok "plan mutation: dropping the newest-release comparison lets an older tag publish the tree — the older-stable case catches it"; else bad "plan mutation newest-release (rc=$RC): $OUTPUT / $(cat "$GITHUB_OUTPUT")"; fi
+else bad "plan mutation newest-release: mutation did not apply: $m"; fi
+
+# ---- plan body mutation: the boolean check is what the null case tests -----------------
+# With the check accepting any value, `null` is not "true" and falls into the
+# stable path — publish_tree=true for a release nobody marked stable.
+if m="$(mutate "s = [s for s in steps if s.get('id') == 'plan'][0]; s['run'] = s['run'].replace('true|false) ;;', '*) ;;')")"; then
+  reset_env; export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_LATEST_TAG=v1.2.3 GH_RELEASE_JSON='{"tagName":"v1.2.3","isDraft":false}'
+  run_step_in "$m" plan
+  if [ "$RC" -eq 0 ] && [ "$(out prerelease)" = null ] && [ "$(out publish_tree)" = true ]; then ok "plan mutation: accepting a non-boolean isPrerelease lets a null release publish the tree — the null case catches it"; else bad "plan mutation isPrerelease (rc=$RC): $OUTPUT / $(cat "$GITHUB_OUTPUT")"; fi
+else bad "plan mutation isPrerelease: mutation did not apply: $m"; fi
+
+# ---- guard-tree body mutation: `|| rc=$?` is what the refusal case tests ---------------
+# Without it, errexit ends the body at the failed pipeline: the step still
+# reddens, but the refusal never reaches the summary.
+if m="$(mutate "s = [s for s in steps if s.get('id') == 'guard-tree'][0]; s['run'] = s['run'].replace(' || rc=\$?', '')")"; then
+  reset_env; fake_guard "$WORK"; export GITHUB_STEP_SUMMARY="$ROOT/summary.md" STRICT="" SRC_DIR=""; : >"$GITHUB_STEP_SUMMARY"
+  run_step_in "$m" guard-tree
+  if [ "$RC" -eq 1 ] && has "REFUSED — planted refusal" && [ ! -s "$GITHUB_STEP_SUMMARY" ]; then ok "guard-tree mutation: without catching the guard's status, errexit skips the summary — the refusal case catches it"; else bad "guard-tree mutation (rc=$RC): $OUTPUT / summary: $(cat "$GITHUB_STEP_SUMMARY")"; fi
+  unset GITHUB_STEP_SUMMARY
+else bad "guard-tree mutation: mutation did not apply: $m"; fi
+
+echo
+printf 'mirror-publish-workflow-verify: %d passed, %d failed\n' "$PASS" "$FAIL"
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 24 ]
diff --git a/scripts/tests/publish-guard-verify.sh b/scripts/tests/publish-guard-verify.sh
new file mode 100755
index 0000000..7ea5483
--- /dev/null
+++ b/scripts/tests/publish-guard-verify.sh
@@ -0,0 +1,337 @@
+#!/usr/bin/env bash
+# =============================================================================
+#  publish-guard-verify.sh — pin the properties of scripts/publish-guard.sh,
+#  the staging guard in front of the public mirror.
+#
+#  Driven the same way install-verify.sh drives install.sh: the REAL script is
+#  executed against a fixture git repository this harness builds, with gitleaks
+#  replaced by a PATH shim so the verdict plumbing is exercised hermetically.
+#  The allowlist and forbidden list the fixtures use are written HERE — never
+#  read from the repo's own .publish-include / .publish-forbidden — so the guard
+#  is not tested against its own copy of the rule. The repo's real lists get
+#  their own cases at the end, fed inputs this file writes.
+#
+#  Three verdicts, and every case names the one it expects: 0 clean, 1 refused
+#  (the `[guard]` and the offending path or needle are asserted, not just the
+#  exit code), 2 could not tell.
+# =============================================================================
+# pipefail so a failing producer is not masked. Deliberately NO -e: this harness
+# counts its own pass/fail and must survive a failed assertion.
+set -uo pipefail
+
+SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
+REPO="$(cd "$SELF_DIR/../.." && pwd)"
+GUARD="$REPO/scripts/publish-guard.sh"
+[ -f "$GUARD" ] || { printf 'publish-guard-verify: %s missing — refusing to report clean\n' "$GUARD" >&2; exit 2; }
+
+PASS=0
+FAIL=0
+ok()  { printf '  ok   %s\n' "$1"; PASS=$((PASS+1)); }
+bad() { printf '  FAIL %s\n' "$1"; FAIL=$((FAIL+1)); }
+
+ROOT="$(mktemp -d "${TMPDIR:-/tmp}/publish-guard-verify.XXXXXX")"
+trap 'rm -rf "$ROOT"' EXIT
+SHIM="$ROOT/shim"; mkdir -p "$SHIM"
+cat >"$SHIM/gitleaks" <<'EOF'
+#!/usr/bin/env bash
+case "${1:-}" in version) echo "shim-9.9.9"; exit 0 ;; esac
+echo "shim gitleaks ran: $*"
+case "${GL_MODE:-clean}" in
+  clean) exit 0 ;;
+  leak)  echo "Finding: REDACTED"; exit 9 ;;
+  crash) echo "panic: shim crash"; exit 1 ;;
+esac
+EOF
+chmod +x "$SHIM/gitleaks"
+export PUBLISH_GUARD_GITLEAKS="$SHIM/gitleaks"
+
+# ---- fixture ---------------------------------------------------------------------
+SRC=""; OUT=""
+add_file() { mkdir -p "$SRC/$(dirname "$1")"; printf '%s\n' "$2" >"$SRC/$1"; git -C "$SRC" add -f "$1"; }
+commit()   { git -C "$SRC" commit -q -m fixture --allow-empty; }
+write_include()   { printf '# fixture allowlist\n' >"$SRC/.publish-include"; printf '%s\n' "$@" >>"$SRC/.publish-include"; }
+write_forbidden() {
+  {
+    printf '[paths]\n'; printf '%s\n' 'tests/' 'scripts/tests/' 'Makefile' 'CLAUDE.md' '.github/' '*.go' 'go.mod' 'kubeconfig*'
+    printf '\n[strings-refuse]\n'; printf '%s\n' '[A-Za-z0-9._%+-]+@tracebloc\.io' 'arn:aws:'
+    printf '\n[strings-report]\n'; printf '%s\n' 'backend#' 'RFC-0' 'dev-api\.tracebloc\.io'
+    printf '\n[allow]\n'; printf '%s\n' 'support@tracebloc\.io'
+  } >"$SRC/.publish-forbidden"
+}
+# fresh NAME — a new fixture repo + empty out dir under $ROOT/NAME.
+fresh() {
+  SRC="$ROOT/$1/src"; OUT="$ROOT/$1/out"
+  mkdir -p "$SRC"
+  git -C "$SRC" init -q
+  git -C "$SRC" config user.email t@example.invalid
+  git -C "$SRC" config user.name t
+  add_file README.md 'Fixture CLI. Help: support@tracebloc.io'
+  add_file LICENSE 'Apache-2.0'
+  add_file docs/usage.md 'usage'
+  add_file docs/rfcs/0001.md 'rfc'
+  add_file scripts/install.sh '#!/bin/sh'
+  add_file scripts/tests/x-verify.sh 'x'
+  add_file Makefile 'all:'
+  add_file CLAUDE.md 'guidance'
+  add_file go.mod 'module x'
+  add_file internal/cli/main.go 'package cli'
+  add_file .github/workflows/ci.yml 'on: push'
+  commit
+  write_include 'README.md' 'LICENSE' 'docs/*.md'
+  write_forbidden
+}
+# plant PATH LINE — append, commit, and PROVE the mutation landed.
+plant() {
+  printf '%s\n' "$2" >>"$SRC/$1"; git -C "$SRC" add "$1"; commit
+  [ "$(grep -cF -- "$2" "$SRC/$1")" -eq 1 ] || { bad "mutation did not land in $1"; return 1; }
+}
+guard() { OUTPUT="$(bash "$GUARD" --source "$SRC" --out "$OUT" "$@" 2>&1)"; RC=$?; }
+has()   { [[ "$OUTPUT" == *"$1"* ]]; }
+staged(){ ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort | paste -sd' ' - ); }
+
+echo "== publish-guard.sh harness =="
+
+# ---- clean case ---------------------------------------------------------------------
+fresh clean; guard
+if [ "$RC" -eq 0 ] && has "[allowlist] staged 3 of 11 tracked file(s)" && has "[forbidden-paths] clean (8 pattern(s) against 3 staged path(s))" \
+   && has "[forbidden-strings] clean (2 refuse + 3 report needle(s), 1 allow token(s); 3 text file(s) scanned, 0 binary" && has "[gitleaks] clean" \
+   && has "publish-guard: OK — all 4 guards ran and passed" && [ "$(staged)" = "LICENSE README.md docs/usage.md" ]; then
+  ok "clean fixture: exactly the allowlisted files are staged, all four guards report, exit 0"
+else bad "clean fixture (rc=$RC, staged='$(staged)'): $OUTPUT"; fi
+
+fresh untracked; printf 'x\n' >"$SRC/docs/scratch.md"; guard
+if [ "$RC" -eq 0 ] && [ ! -e "$OUT/tree/docs/scratch.md" ]; then ok "an untracked file matching the allowlist is not staged"; else bad "untracked file (rc=$RC): $OUTPUT"; fi
+
+# docs/*.md is one level: docs/rfcs/0001.md is not staged by the glob at all.
+fresh onelevel; guard
+if [ "$RC" -eq 0 ] && [ ! -e "$OUT/tree/docs/rfcs" ]; then ok "docs/*.md does not cross into docs/rfcs/ (glob * does not cross /)"; else bad "one-level glob (rc=$RC)"; fi
+
+# ---- guard 2: forbidden paths -----------------------------------------------------------
+fresh gofile; write_include 'README.md' 'internal/**'; guard
+if [ "$RC" -eq 1 ] && has "[forbidden-paths] REFUSED — forbidden path pattern '*.go' matched:" && has "tree:internal/cli/main.go" \
+   && has "[forbidden-strings] clean" && has "[gitleaks] clean"; then
+  ok "mutation: allowlisting Go source is refused by *.go, and the later guards still run"
+else bad "go source (rc=$RC): $OUTPUT"; fi
+
+fresh gomod; write_include 'README.md' 'go.mod' 'Makefile' 'CLAUDE.md'; guard
+if [ "$RC" -eq 1 ] && has "pattern 'go.mod' matched:" && has "tree:go.mod" && has "pattern 'Makefile' matched:" && has "pattern 'CLAUDE.md' matched:"; then
+  ok "mutation: go.mod, Makefile and CLAUDE.md are each refused by name"
+else bad "go.mod/Makefile/CLAUDE.md (rc=$RC): $OUTPUT"; fi
+
+fresh workflows; write_include 'README.md' '.github/**'; guard
+if [ "$RC" -eq 1 ] && has "pattern '.github/' matched:" && has "tree:.github/workflows/ci.yml"; then ok "mutation: a workflow directory is refused by .github/"; else bad ".github (rc=$RC): $OUTPUT"; fi
+
+fresh anchored; write_include 'README.md' 'scripts/tests/**'; printf '[paths]\nscripts/tests/\n[strings-refuse]\narn:aws:\n' >"$SRC/.publish-forbidden"; guard
+if [ "$RC" -eq 1 ] && has "pattern 'scripts/tests/' matched:" && has "tree:scripts/tests/x-verify.sh"; then ok "an anchored directory pattern refuses the root-level directory"; else bad "anchored (rc=$RC): $OUTPUT"; fi
+
+fresh asset-kube; mkdir -p "$ROOT/asset-kube/assets"; printf 'k\n' >"$ROOT/asset-kube/assets/kubeconfig"; guard --assets "$ROOT/asset-kube/assets"
+if [ "$RC" -eq 1 ] && has "pattern 'kubeconfig*' matched:" && has "assets:kubeconfig"; then ok "a release asset named like a credential file is refused"; else bad "asset kubeconfig (rc=$RC): $OUTPUT"; fi
+
+fresh nopaths; printf '[strings-refuse]\narn:aws:\n' >"$SRC/.publish-forbidden"; guard
+if [ "$RC" -eq 2 ] && has "[forbidden-paths] COULD NOT TELL" && has "has no [paths] entries"; then ok "no [paths] entries is could-not-tell"; else bad "no paths (rc=$RC): $OUTPUT"; fi
+
+# ---- guard 3: forbidden strings — the refuse tier ----------------------------------------
+fresh ref; plant README.md 'role arn:aws:iam::000000000000:role/planted' && guard
+if [ "$RC" -eq 1 ] && has "[forbidden-strings] REFUSED — [strings-refuse] needle 'arn:aws:' found in 1 staged line(s):" && has "    tree/README.md:2" && ! has "role/planted" \
+   && has "[forbidden-strings] 1 refuse-tier hit(s), 0 report-tier hit(s) counted"; then
+  ok "mutation: a refuse-tier needle is refused; tier and file:line named, text not echoed"
+else bad "refuse tier (rc=$RC): $OUTPUT"; fi
+
+fresh case; plant README.md 'ARN:AWS:s3:::planted' && guard
+if [ "$RC" -eq 1 ] && has "[strings-refuse] needle 'arn:aws:' found in 1 staged line(s)"; then ok "needles match case-insensitively"; else bad "case (rc=$RC): $OUTPUT"; fi
+
+fresh allow; guard; a="$RC"; rm -rf "$OUT"; plant README.md 'or someone@tracebloc.io / support@tracebloc.io' && guard
+if [ "$a" -eq 0 ] && [ "$RC" -eq 1 ] && has "[strings-refuse] needle '[A-Za-z0-9._%+-]+@tracebloc\.io' found in 1 staged line(s):" && has "tree/README.md:2"; then
+  ok "[allow] spares the support mailbox alone, not a personal mailbox beside it"
+else bad "allow (first rc=$a, second rc=$RC): $OUTPUT"; fi
+
+# The unanchored strip this replaces left `dev` behind and the mailbox rule no
+# longer matched, so an internal address ending in the public one shipped.
+fresh allow-tail; plant README.md 'escalate to devsupport@tracebloc.io' && guard
+if [ "$RC" -eq 1 ] && has "[strings-refuse] needle '[A-Za-z0-9._%+-]+@tracebloc\.io' found in 1 staged line(s):" && has "tree/README.md:2"; then
+  ok "mutation: an [allow] token is stripped as a whole word only — a mailbox that merely ends in it is refused"
+else bad "allow tail (rc=$RC): $OUTPUT"; fi
+
+fresh allow-case; plant README.md 'Questions? Write to Support@Tracebloc.io.' && guard
+if [ "$RC" -eq 0 ] && has "[forbidden-strings] clean ("; then ok "an [allow] token matches case-insensitively, like the scan, and a sentence-ending dot is still a boundary"; else bad "allow case (rc=$RC): $OUTPUT"; fi
+
+fresh extra; printf 'planted-tenant\n' >"$ROOT/extra/tenants.txt"; plant docs/usage.md 'for Planted-Tenant' && guard --extra-forbidden "$ROOT/extra/tenants.txt"
+# The private pattern is the identifier kept out of the public list; it must not
+# surface in the log (teed into the public run summary) or in the report.
+if [ "$RC" -eq 1 ] && has "[strings-refuse] private needle #1 found in 1 staged line(s):" && has "tree/docs/usage.md:2" && has "(3 refuse + 3 report needle(s)" \
+   && ! grep -qi 'planted-tenant' <<<"$OUTPUT" && ! grep -qi 'planted-tenant' "$OUT/publish-guard-report.txt"; then
+  ok "mutation: a private needle from --extra-forbidden joins the refuse tier, named by number only"
+else bad "extra needle (rc=$RC): $OUTPUT"; fi
+
+fresh extra-empty; printf '# none\n\n' >"$ROOT/extra-empty/tenants.txt"; guard --extra-forbidden "$ROOT/extra-empty/tenants.txt"
+if [ "$RC" -eq 2 ] && has "[forbidden-strings] COULD NOT TELL — extra forbidden list '" && has "' is empty"; then ok "an empty --extra-forbidden list is could-not-tell"; else bad "extra empty (rc=$RC): $OUTPUT"; fi
+
+fresh extra-missing; guard --extra-forbidden "$ROOT/extra-missing/absent.txt"
+if [ "$RC" -eq 2 ] && has "extra forbidden list '" && has "absent.txt' is missing or unreadable"; then ok "a missing --extra-forbidden list is could-not-tell"; else bad "extra missing (rc=$RC): $OUTPUT"; fi
+
+fresh asset-ref; mkdir -p "$ROOT/asset-ref/assets"; printf '#!/bin/sh\n# arn:aws:s3:::planted\n' >"$ROOT/asset-ref/assets/install.sh"; guard --assets "$ROOT/asset-ref/assets"
+if [ "$RC" -eq 1 ] && has "[assets] staged 1 release asset(s):" && has "[strings-refuse] needle 'arn:aws:' found in 1 staged line(s):" && has "assets/install.sh:2"; then
+  ok "a refuse-tier needle inside a release asset is refused with the asset named"
+else bad "asset ref (rc=$RC): $OUTPUT"; fi
+
+fresh binary; mkdir -p "$ROOT/binary/assets"; printf 'ELF\000arn:aws:x\000' >"$ROOT/binary/assets/tracebloc-linux-amd64"; guard --assets "$ROOT/binary/assets"
+if [ "$RC" -eq 0 ] && has "3 text file(s) scanned, 1 binary file(s) opaque to this scan"; then ok "a binary asset is opaque to the string scan and counted as such"; else bad "binary (rc=$RC): $OUTPUT"; fi
+
+# ---- guard 3: forbidden strings — the report tier and --strict -----------------------------
+fresh report; plant README.md 'see backend#1234 for the rationale' && guard
+if [ "$RC" -eq 0 ] && has "[forbidden-strings] [strings-report] needle 'backend#' found in 1 staged line(s) — counted, not refused (--strict refuses)" \
+   && has "[forbidden-strings] [strings-report] 1 hit(s) in 1 file(s); most-hit files:" && has "         1  tree/README.md" \
+   && has "[forbidden-strings] 0 refuse-tier hit(s), 1 report-tier hit(s) counted" && ! has "REFUSED" && has "publish-guard: OK — all 4 guards ran and passed" \
+   && ! has "for the rationale" && grep -qF "[strings-report] needle 'backend#':" "$OUT/publish-guard-report.txt" && grep -qF "tree/README.md:2" "$OUT/publish-guard-report.txt"; then
+  ok "a report-tier hit alone is counted, not refused: exit 0, per-needle total, most-hit files, report written"
+else bad "report tier (rc=$RC): $OUTPUT"; fi
+
+fresh strict; plant README.md 'see backend#1234 for the rationale' && guard --strict
+if [ "$RC" -eq 1 ] && has "[forbidden-strings] REFUSED — [strings-report (strict)] needle 'backend#' found in 1 staged line(s):" && has "    tree/README.md:2" \
+   && has "[forbidden-strings] 0 refuse-tier hit(s), 1 report-tier hit(s) refused under --strict" && has "publish-guard: REFUSED — do not publish"; then
+  ok "mutation: the same report-tier hit under --strict is refused, tier named"
+else bad "strict (rc=$RC): $OUTPUT"; fi
+
+fresh strict-clean; guard --strict
+if [ "$RC" -eq 0 ] && has "[forbidden-strings] clean (2 refuse + 3 report needle(s)"; then ok "--strict with no report-tier hit is still clean"; else bad "strict clean (rc=$RC): $OUTPUT"; fi
+
+fresh host; plant docs/usage.md 'API dev-api.tracebloc.io' && guard; a="$RC"; b="$OUTPUT"; rm -rf "$OUT"; guard --strict
+if [ "$a" -eq 0 ] && [[ "$b" == *"[strings-report] needle 'dev-api\.tracebloc\.io' found in 1 staged line(s) — counted, not refused"* ]] \
+   && [ "$RC" -eq 1 ] && has "REFUSED — [strings-report (strict)] needle 'dev-api\.tracebloc\.io' found in 1 staged line(s):" && has "tree/docs/usage.md:2"; then
+  ok "a non-production hostname is report-tier: counted, and refused under --strict"
+else bad "hostname (a=$a rc=$RC): $b // $OUTPUT"; fi
+
+fresh table; plant README.md 'backend#1 and RFC-0001 on one line' && plant README.md 'backend#2 on another' && plant docs/usage.md '# backend#3' && guard
+if [ "$RC" -eq 0 ] && has "[strings-report] needle 'backend#' found in 3 staged line(s)" && has "[strings-report] needle 'RFC-0' found in 1 staged line(s)" \
+   && [[ "$OUTPUT" == *"[strings-report] 4 hit(s) in 2 file(s); most-hit files:"*"         3  tree/README.md"*"         1  tree/docs/usage.md"* ]]; then
+  ok "the most-hit table sums every report-tier needle per file, largest first"
+else bad "table (rc=$RC): $OUTPUT"; fi
+
+fresh table-cap; for i in 01 02 03 04 05 06 07 08 09 10 11; do add_file "docs/n$i.md" "ref backend#$i"; done; commit; guard
+if [ "$RC" -eq 0 ] && has "[strings-report] 11 hit(s) in 11 file(s); most-hit files:" && [ "$(printf '%s\n' "$OUTPUT" | grep -cE '^ +[0-9]+  (tree|assets)/')" -eq 10 ]; then
+  ok "the most-hit table stops at ten rows"
+else bad "table cap (rc=$RC): $OUTPUT"; fi
+
+fresh both; plant README.md 'arn:aws:iam::000000000000:root — see backend#9' && guard
+if [ "$RC" -eq 1 ] && has "REFUSED — [strings-refuse] needle 'arn:aws:' found in 1 staged line(s):" && has "[strings-report] needle 'backend#' found in 1 staged line(s) — counted, not refused" \
+   && has "[forbidden-strings] 1 refuse-tier hit(s), 1 report-tier hit(s) counted"; then
+  ok "a refuse-tier and a report-tier hit in one run: refused, and the report tier still counted"
+else bad "both tiers (rc=$RC): $OUTPUT"; fi
+
+# ---- guard 3: the forbidden list itself ----------------------------------------------------
+fresh norefuse; printf '[paths]\ntests/\n[strings-report]\nbackend#\n' >"$SRC/.publish-forbidden"; guard; a="$RC"; b="$OUTPUT"
+rm -rf "$OUT"; printf '[paths]\ntests/\n[strings-refuse]\n# none yet\n[strings-report]\nbackend#\n' >"$SRC/.publish-forbidden"; guard
+if [ "$a" -eq 2 ] && [[ "$b" == *"[forbidden-strings] COULD NOT TELL — '"*"' has no [strings-refuse] entries — a guard with nothing to refuse is misconfigured"* ]] \
+   && [ "$RC" -eq 2 ] && has "has no [strings-refuse] entries"; then
+  ok "no [strings-refuse] entries (absent or empty section) is could-not-tell"
+else bad "no refuse tier (a=$a rc=$RC): $b // $OUTPUT"; fi
+
+fresh norefuse-extra; printf '[paths]\ntests/\n[strings-report]\nbackend#\n' >"$SRC/.publish-forbidden"; printf 'planted-tenant\n' >"$ROOT/norefuse-extra/tenants.txt"; guard --extra-forbidden "$ROOT/norefuse-extra/tenants.txt"
+if [ "$RC" -eq 2 ] && has "has no [strings-refuse] entries"; then ok "an empty [strings-refuse] is judged before the private needles join it"; else bad "no refuse tier + extra (rc=$RC): $OUTPUT"; fi
+
+fresh dup; printf '[paths]\ntests/\n[strings-refuse]\narn:aws:\nbackend#\n[strings-report]\nbackend#\nRFC-0\n' >"$SRC/.publish-forbidden"; guard
+if [ "$RC" -eq 2 ] && has "[forbidden-strings] COULD NOT TELL — '" && has "' lists needle 'backend#' in both [strings-refuse] and [strings-report] — a needle has one tier"; then
+  ok "a needle listed in both string tiers is could-not-tell, the duplicate named"
+else bad "duplicate needle (rc=$RC): $OUTPUT"; fi
+
+fresh unknown; printf '[paths]\ntests/\n[strings]\narn:aws:\n' >"$SRC/.publish-forbidden"; guard; a="$RC"; b="$OUTPUT"
+rm -rf "$OUT"; printf '[paths]\ntests/\n[strings-refuse]\narn:aws:\n[strings report]\nbackend#\n' >"$SRC/.publish-forbidden"; guard
+if [ "$a" -eq 2 ] && [[ "$b" == *"[forbidden-paths] COULD NOT TELL — '"*"' has an unknown section [strings] — the guard reads only [paths] [strings-refuse] [strings-report] [allow]"* ]] \
+   && [[ "$b" == *"[forbidden-strings] COULD NOT TELL — '"*"' has an unknown section [strings]"* ]] \
+   && [ "$RC" -eq 2 ] && has "has an unknown section [strings report]" && ! has "needle 'backend#'"; then
+  ok "an unknown section header (the retired [strings], a header with a space) is could-not-tell for both scans"
+else bad "unknown section (a=$a rc=$RC): $b // $OUTPUT"; fi
+
+fresh noforbidden; rm "$SRC/.publish-forbidden"; guard
+if [ "$RC" -eq 2 ] && has "[forbidden-paths] COULD NOT TELL — forbidden list '" && has "[forbidden-strings] COULD NOT TELL — forbidden list '"; then ok "a missing forbidden list is could-not-tell for both scans"; else bad "no forbidden (rc=$RC): $OUTPUT"; fi
+
+# ---- guard 1: allowlist fail-closed -------------------------------------------------------
+fresh emptyinc; printf '# comments only\n' >"$SRC/.publish-include"; guard
+if [ "$RC" -eq 2 ] && has "[allowlist] COULD NOT TELL" && has "lists no include entries"; then ok "an allowlist with no include entries is could-not-tell"; else bad "empty include (rc=$RC): $OUTPUT"; fi
+
+fresh noinc; rm "$SRC/.publish-include"; guard
+if [ "$RC" -eq 2 ] && has "[allowlist] COULD NOT TELL — allowlist '" && has "is missing or unreadable"; then ok "a missing allowlist is could-not-tell"; else bad "missing include (rc=$RC): $OUTPUT"; fi
+
+fresh nomatch; write_include 'nothing/**'; guard
+if [ "$RC" -eq 2 ] && has "the allowlist matched none of the 11 tracked files"; then ok "an allowlist matching nothing is could-not-tell"; else bad "no match (rc=$RC): $OUTPUT"; fi
+
+fresh symlink; ln -s ../Makefile "$SRC/docs/link.md"; git -C "$SRC" add docs/link.md; commit; guard
+if [ "$RC" -eq 2 ] && has "'docs/link.md' is a symlink"; then ok "a symlink in the allowlisted set is could-not-tell"; else bad "symlink (rc=$RC): $OUTPUT"; fi
+
+fresh dirty-out; mkdir -p "$OUT"; printf 's\n' >"$OUT/stale"; guard
+if [ "$RC" -eq 2 ] && has "is not empty"; then ok "a non-empty --out is could-not-tell"; else bad "dirty out (rc=$RC): $OUTPUT"; fi
+
+fresh noassets; mkdir -p "$ROOT/noassets/assets"; guard --assets "$ROOT/noassets/assets"
+if [ "$RC" -eq 2 ] && has "holds no files"; then ok "an --assets directory with no files is could-not-tell"; else bad "no assets (rc=$RC): $OUTPUT"; fi
+
+# ---- guard 4: gitleaks plumbing -----------------------------------------------------------
+fresh gl-missing; PUBLISH_GUARD_GITLEAKS="$ROOT/no-such-gitleaks" guard
+if [ "$RC" -eq 2 ] && has "[gitleaks] COULD NOT TELL — scanner '" && has "is not on PATH" && has "publish-guard: COULD NOT TELL — do not publish"; then ok "a missing scanner is could-not-tell, never clean"; else bad "gl missing (rc=$RC): $OUTPUT"; fi
+
+fresh gl-leak; GL_MODE=leak guard
+if [ "$RC" -eq 1 ] && has "[gitleaks] REFUSED — secrets detected in the staged tree:" && has "Finding: REDACTED" && has "shim gitleaks ran: detect --no-git --redact --no-banner --exit-code 9 --source "; then
+  ok "a scanner finding refuses; the scanner ran with --no-git --redact over the staged tree"
+else bad "gl leak (rc=$RC): $OUTPUT"; fi
+
+fresh gl-crash; GL_MODE=crash guard
+if [ "$RC" -eq 2 ] && has "[gitleaks] COULD NOT TELL — scanner exited 1"; then ok "a scanner crash is could-not-tell"; else bad "gl crash (rc=$RC): $OUTPUT"; fi
+
+if command -v gitleaks >/dev/null 2>&1; then
+  fresh gl-real
+  key="AKIA$(LC_ALL=C tr -dc 'A-Z2-7' &1)"; RC=$?
+missing=""; for f in README.md LICENSE docs/troubleshooting.md; do [ -f "$ROOT/real/out/tree/$f" ] || missing="$missing $f"; done
+present=""; for f in go.mod go.sum Makefile CLAUDE.md STYLE.md cmd internal .github .cursor scripts docs/rfcs; do [ ! -e "$ROOT/real/out/tree/$f" ] || present="$present $f"; done
+if [ "$RC" -eq 0 ] && has "[forbidden-paths] clean" && { has "[forbidden-strings] 0 refuse-tier hit(s), " || has "[forbidden-strings] clean ("; } && [ -z "$missing" ] && [ -z "$present" ]; then
+  ok "the committed .publish-include stages README/LICENSE/docs of the real repo and no source; the refuse tier holds"
+else bad "real allowlist (rc=$RC, missing:$missing, staged-but-forbidden:$present): $OUTPUT"; fi
+
+echo
+printf 'publish-guard-verify: %d passed, %d failed\n' "$PASS" "$FAIL"
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 42 ]
diff --git a/scripts/tests/publish-mirror-verify.sh b/scripts/tests/publish-mirror-verify.sh
new file mode 100755
index 0000000..5042708
--- /dev/null
+++ b/scripts/tests/publish-mirror-verify.sh
@@ -0,0 +1,155 @@
+#!/usr/bin/env bash
+# =============================================================================
+#  publish-mirror-verify.sh — pin the properties of scripts/publish-mirror.sh,
+#  the publish half of the mirror pipeline.
+#
+#  `tree` is driven against REAL bare repositories over file:// (the clone /
+#  replace / commit / plain-push path is the production one); `release` against
+#  a recording `gh` shim, since a real release needs GitHub. `target` is pure.
+#
+#  Pinned: the refusals that keep a publish from landing in the wrong place (no
+#  mirror named, the mirror IS the source, an unreachable remote), that the
+#  mirror branch ends up holding EXACTLY the stage (removed files vanish,
+#  history is appended, never rewritten), and that a mirrored release is never
+#  overwritten.
+# =============================================================================
+set -uo pipefail
+
+SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
+PUB="$SELF_DIR/../publish-mirror.sh"
+[ -f "$PUB" ] || { printf 'publish-mirror-verify: %s missing — refusing to report clean\n' "$PUB" >&2; exit 2; }
+
+PASS=0
+FAIL=0
+ok()  { printf '  ok   %s\n' "$1"; PASS=$((PASS+1)); }
+bad() { printf '  FAIL %s\n' "$1"; FAIL=$((FAIL+1)); }
+
+ROOT="$(mktemp -d "${TMPDIR:-/tmp}/publish-mirror-verify.XXXXXX")"
+trap 'rm -rf "$ROOT"' EXIT
+SHIM="$ROOT/shim"; mkdir -p "$SHIM"
+cat >"$SHIM/gh" <<'EOF'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >>"${GH_LOG:?}"
+if [ "${1:-}" = release ] && [ "${2:-}" = view ]; then
+  printf '%s\n' "${GH_VIEW_ERR:-release not found}" >&2
+  exit "${GH_VIEW_RC:-1}"
+fi
+exit 0
+EOF
+chmod +x "$SHIM/gh"
+export GH_LOG="$ROOT/gh.log"
+
+pub() { OUTPUT="$(bash "$PUB" "$@" 2>&1)"; RC=$?; }
+has() { [[ "$OUTPUT" == *"$1"* ]]; }
+
+echo "== publish-mirror.sh harness =="
+
+# ---- target ------------------------------------------------------------------------
+pub target --mirror '' --source-repo tracebloc/cli
+if [ "$RC" -eq 1 ] && has "REFUSED — no mirror repository is configured (MIRROR_REPO is unset)"; then ok "target: no mirror configured is refused — there is no default"; else bad "target unset (rc=$RC): $OUTPUT"; fi
+
+pub target --mirror cli --source-repo tracebloc/cli; a="$RC"; pub target --mirror CLI --source-repo tracebloc/cli
+if [ "$a" -eq 1 ] && [ "$RC" -eq 1 ] && has "REFUSED — mirror 'tracebloc/CLI' is this repository"; then ok "target: the source repository itself is refused, case-insensitively"; else bad "target self (a=$a rc=$RC): $OUTPUT"; fi
+
+pub target --mirror 'cli mirror' --source-repo tracebloc/cli; a="$RC"; o1="$OUTPUT"; pub target --mirror 'other/cli' --source-repo tracebloc/cli
+if [ "$a" -eq 1 ] && [[ "$o1" == *"contains characters a repository name cannot"* ]] && [ "$RC" -eq 1 ] && has "must be a bare repository name"; then ok "target: bad characters and OWNER/NAME are refused"; else bad "target shape (a=$a rc=$RC): $o1 / $OUTPUT"; fi
+
+pub target --mirror cli-public --source-repo tracebloc/cli
+if [ "$RC" -eq 0 ] && [ "$OUTPUT" = "tracebloc/cli-public" ]; then ok "target: a valid mirror prints OWNER/NAME in the source's organisation"; else bad "target ok (rc=$RC): $OUTPUT"; fi
+
+pub target --mirror cli-public
+if [ "$RC" -eq 2 ] && has "COULD NOT TELL — target: --source-repo is required"; then ok "target: a missing --source-repo is could-not-tell"; else bad "target no source (rc=$RC): $OUTPUT"; fi
+
+# --output: the workflow runs the publisher DIRECTLY and reads results from a
+# file, so a refusal's ::error:: line is on stdout where Actions annotates it —
+# captured through $(...) it would be swallowed by set -e (Bugbot on the PR).
+OUTF="$ROOT/out"
+pub target --mirror cli-public --source-repo tracebloc/cli --output "$OUTF"
+if [ "$RC" -eq 0 ] && [ "$OUTPUT" = "tracebloc/cli-public" ] && [ "$(cat "$OUTF")" = $'repo=tracebloc/cli-public\nname=cli-public' ]; then ok "target: --output writes repo= and name=; stdout still names the mirror"; else bad "target output (rc=$RC): $OUTPUT / $(cat "$OUTF" 2>&1)"; fi
+rm -f "$OUTF"
+pub target --mirror '' --source-repo tracebloc/cli --output "$OUTF"; a="$RC"; o1="$OUTPUT"
+pub target --mirror cli --source-repo tracebloc/cli --output "$OUTF"
+if [ "$a" -eq 1 ] && [[ "$o1" == "::error::publish-mirror: REFUSED — no mirror repository is configured"* ]] && [ "$RC" -eq 1 ] && [ ! -e "$OUTF" ]; then ok "target: a refusal puts the ::error:: line on stdout and writes nothing to --output"; else bad "target refusal output (a=$a rc=$RC, out exists=$([ -e "$OUTF" ] && echo yes || echo no)): $o1"; fi
+
+# ---- tree ---------------------------------------------------------------------------
+STAGE="$ROOT/stage"; mkdir -p "$STAGE/docs"
+printf 'readme\n' >"$STAGE/README.md"; printf 'license\n' >"$STAGE/LICENSE"; printf 'doc\n' >"$STAGE/docs/a.md"
+BARE="$ROOT/mirror.git"; git init -q --bare "$BARE"
+tree() { pub tree --stage "$STAGE" --repo tracebloc/mirror --branch main --message "Publish v1.0.0" --remote "file://$BARE" "$@"; }
+mirror_files() { git -C "$BARE" ls-tree -r --name-only main | sort | paste -sd' ' -; }
+
+tree
+if [ "$RC" -eq 0 ] && [[ "$OUTPUT" == pushed\ [0-9a-f]* ]] && [ "$(mirror_files)" = "LICENSE README.md docs/a.md" ] && [ "$(git -C "$BARE" rev-list --count main)" -eq 1 ]; then
+  ok "tree: the first publish starts the branch; the mirror holds exactly the stage"
+else bad "tree first (rc=$RC, files='$(mirror_files)'): $OUTPUT"; fi
+first="$(git -C "$BARE" rev-parse main)"
+
+tree
+if [ "$RC" -eq 0 ] && [[ "$OUTPUT" == unchanged\ [0-9a-f]* ]] && [ "$(git -C "$BARE" rev-list --count main)" -eq 1 ]; then ok "tree: an identical stage is a no-op, reported as unchanged"; else bad "tree unchanged (rc=$RC): $OUTPUT"; fi
+
+rm "$STAGE/docs/a.md"; printf 'new\n' >"$STAGE/CHANGES.md"; tree
+if [ "$RC" -eq 0 ] && [ "$(mirror_files)" = "CHANGES.md LICENSE README.md" ] && [ "$(git -C "$BARE" rev-list --count main)" -eq 2 ] && [ "$(git -C "$BARE" rev-parse main^)" = "$first" ]; then
+  ok "tree: a later publish replaces the content — removed files vanish, history is appended"
+else bad "tree replace (rc=$RC, files='$(mirror_files)'): $OUTPUT"; fi
+
+pub tree --stage "$STAGE" --repo tracebloc/mirror --branch main --message m --remote "file://$ROOT/no-such.git"
+if [ "$RC" -eq 2 ] && has "COULD NOT TELL — tree: the mirror remote did not answer"; then ok "tree: an unreachable remote is could-not-tell, not a fresh start"; else bad "tree unreachable (rc=$RC): $OUTPUT"; fi
+
+EMPTY="$ROOT/empty"; mkdir -p "$EMPTY"
+pub tree --stage "$EMPTY" --repo tracebloc/mirror --branch main --message m --remote "file://$BARE"; a="$RC"; o1="$OUTPUT"
+mkdir -p "$STAGE/.git"; tree; rm -r "$STAGE/.git"
+if [ "$a" -eq 2 ] && [[ "$o1" == *"holds no files"* ]] && [ "$RC" -eq 2 ] && has "contains a .git entry"; then ok "tree: an empty stage, or one that is a checkout, is could-not-tell"; else bad "tree stage shape (a=$a rc=$RC): $o1 / $OUTPUT"; fi
+
+if ! grep -qE -- '--force|\+refs/|-f[[:space:]]' "$PUB"; then ok "tree: the script never forces a push"; else bad "a force-push spelling is present in $PUB"; fi
+
+BARE2="$ROOT/mirror2.git"; git init -q --bare "$BARE2"
+tree2() { pub tree --stage "$STAGE" --repo tracebloc/mirror --branch main --message "Publish v1.0.0" --remote "file://$BARE2" "$@"; }
+rm -f "$OUTF"; tree2 --output "$OUTF"; a="$RC"; l1="$(sed -n 1p "$OUTF" 2>/dev/null)"; l2="$(sed -n 2p "$OUTF" 2>/dev/null)"
+rm -f "$OUTF"; tree2 --output "$OUTF"; b="$RC"; m1="$(sed -n 1p "$OUTF" 2>/dev/null)"; m2="$(sed -n 2p "$OUTF" 2>/dev/null)"
+head2="$(git -C "$BARE2" rev-parse main)"
+if [ "$a" -eq 0 ] && [ "$l1" = "result=pushed" ] && [ "$l2" = "sha=$head2" ] && [ "$b" -eq 0 ] && [ "$m1" = "result=unchanged" ] && [ "$m2" = "sha=$head2" ]; then
+  ok "tree: --output writes result= and sha= (pushed, then unchanged)"
+else bad "tree output (a=$a b=$b): '$l1' '$l2' / '$m1' '$m2' head=$head2"; fi
+
+rm -f "$OUTF"
+pub tree --stage "$STAGE" --repo tracebloc/mirror --branch main --message m --remote "file://$ROOT/no-such.git" --output "$OUTF"
+if [ "$RC" -eq 2 ] && [[ "$OUTPUT" == "::error::publish-mirror: COULD NOT TELL — tree: the mirror remote did not answer"* ]] && [ ! -e "$OUTF" ]; then ok "tree: a refusal annotates stdout and writes nothing to --output"; else bad "tree refusal output (rc=$RC, out exists=$([ -e "$OUTF" ] && echo yes || echo no)): $OUTPUT"; fi
+
+tree2 --output "$ROOT/no-such-dir/out"
+if [ "$RC" -eq 2 ] && has "COULD NOT TELL — could not write results to"; then ok "tree: an unwritable --output is could-not-tell — a result the caller never receives is not a publish"; else bad "tree unwritable output (rc=$RC): $OUTPUT"; fi
+
+# ---- release ------------------------------------------------------------------------
+NOTES="$ROOT/notes.md"; printf 'Release notes\n' >"$NOTES"
+SHA=0123456789abcdef0123456789abcdef01234567
+release() { : >"$GH_LOG"; PATH="$SHIM:$PATH" pub release --tag v1.0.0 --repo tracebloc/mirror --target "$SHA" --assets "$STAGE" --notes "$NOTES" "$@"; }
+
+release
+create="$(grep '^release create' "$GH_LOG")"
+if [ "$RC" -eq 0 ] && [ "$OUTPUT" = "released v1.0.0 on tracebloc/mirror at $SHA with 3 asset(s)" ] && grep -q '^release view v1.0.0 --repo tracebloc/mirror$' "$GH_LOG" \
+   && [ "$create" = "release create v1.0.0 --repo tracebloc/mirror --target $SHA --title v1.0.0 --notes-file $NOTES $STAGE/CHANGES.md $STAGE/LICENSE $STAGE/README.md" ]; then
+  ok "release: creates the tag at the target with every asset, fixed notes, no --prerelease"
+else bad "release create (rc=$RC): $OUTPUT / $create"; fi
+
+release --prerelease
+if [ "$RC" -eq 0 ] && grep -q '^release create .* --prerelease ' "$GH_LOG"; then ok "release: --prerelease is passed through"; else bad "release prerelease (rc=$RC): $OUTPUT"; fi
+
+GH_VIEW_RC=0 release
+if [ "$RC" -eq 1 ] && has "REFUSED — release: 'v1.0.0' already exists on 'tracebloc/mirror'" && ! grep -q '^release create' "$GH_LOG"; then ok "release: an existing tag on the mirror is refused, never overwritten"; else bad "release exists (rc=$RC): $OUTPUT"; fi
+
+GH_VIEW_RC=1 GH_VIEW_ERR='HTTP 401: Bad credentials' release
+if [ "$RC" -eq 2 ] && has "COULD NOT TELL — release: could not read releases of 'tracebloc/mirror'" && ! grep -q '^release create' "$GH_LOG"; then ok "release: a view failure that is not 'not found' is could-not-tell"; else bad "release view error (rc=$RC): $OUTPUT"; fi
+
+: >"$GH_LOG"
+PATH="$SHIM:$PATH" pub release --tag main --repo tracebloc/mirror --target "$SHA" --assets "$STAGE" --notes "$NOTES"; a="$RC"; o1="$OUTPUT"
+PATH="$SHIM:$PATH" pub release --tag v1.0.0 --repo tracebloc/mirror --target abc123 --assets "$STAGE" --notes "$NOTES"; b="$RC"; o2="$OUTPUT"
+: >"$ROOT/empty.md"
+PATH="$SHIM:$PATH" pub release --tag v1.0.0 --repo tracebloc/mirror --target "$SHA" --assets "$STAGE" --notes "$ROOT/empty.md"; c="$RC"; o3="$OUTPUT"
+PATH="$SHIM:$PATH" pub release --tag v1.0.0 --repo tracebloc/mirror --target "$SHA" --assets "$EMPTY" --notes "$NOTES"; d="$RC"; o4="$OUTPUT"
+if [ "$a" -eq 1 ] && [[ "$o1" == *"'main' is not a release tag"* ]] && [ "$b" -eq 2 ] && [[ "$o2" == *"is not a full commit sha"* ]] \
+   && [ "$c" -eq 2 ] && [[ "$o3" == *"is missing or empty"* ]] && [ "$d" -eq 2 ] && [[ "$o4" == *"holds no files"* ]] && ! grep -q '^release create' "$GH_LOG"; then
+  ok "release: a malformed tag is refused; a short sha, empty notes or no assets are could-not-tell; nothing was created"
+else bad "release inputs (a=$a b=$b c=$c d=$d): $o1 / $o2 / $o3 / $o4"; fi
+
+echo
+printf 'publish-mirror-verify: %d passed, %d failed\n' "$PASS" "$FAIL"
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 19 ]

From 2d616f0bfc2dcf3deb09bf50f2e4aea1fa13b200 Mon Sep 17 00:00:00 2001
From: Arturo Peroni 
Date: Fri, 11 Sep 2026 11:35:32 +0200
Subject: [PATCH 2/4] =?UTF-8?q?[settings-naming]=20cli:=20normalize=20env?=
 =?UTF-8?q?=20vars=20=E2=80=94=20TRACEBLOC=5FENV=20alias=20+=20TRACEBLOC?=
 =?UTF-8?q?=5F=20prefix=20(#657)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat(env): alias-first TRACEBLOC_ENV stage var, normalize toward RFC-0076

Normalize this repo's owned stage-selecting env var toward the RFC-0076
canon (backend#3391), alias-first — read new-or-old, never break an
existing deployment. TRACEBLOC_ENV is now the canonical name; the legacy
CLIENT_ENV is read as a fallback (remove_by: 2026-12-31).

- api.ResolveEnv: --env flag, then $TRACEBLOC_ENV, then legacy $CLIENT_ENV,
  then prod. Alias precedence lives in one place (stageFromEnv).
- doctor: the cluster's stage, read off the jobs-manager Deployment spec, is
  now read alias-first (stageFromClusterSpec) — a consumer-side alias so the
  edge chart can adopt the canonical key on its own S3-edge timeline.
- auth login / status --check help + the unknown-env error name the canonical
  var (legacy noted); goldens regenerated.
- env-resolution guard registers TRACEBLOC_ENV as a needle so a new read of
  either name lands in the allowlist; TestMain clears both stage vars so the
  higher-precedence canonical name can't make CLIENT_ENV-only isolation flaky.
- VERSION 0.10.24 -> 0.10.25 (version-bump-gate: env reads are packaged paths).

The other RFC-0076 config keys (registry REGISTRY_URL, telemetry, boolean
gates) have no owned, unprefixed occurrence here: TRACEBLOC_ALLOW_UNVERIFIED
already carries the prefix and no REGISTRY_URL/SKIP_TELEMETRY var exists.

Co-Authored-By: Claude Opus 4.8 

* fix(cli): errcheck TestMain os.Unsetenv + bump VERSION 0.10.26 after develop merge (cli#656)

Co-Authored-By: Claude Opus 4.8 

---------

Co-authored-by: Claude Opus 4.8 
---
 VERSION                                       |  2 +-
 internal/api/client.go                        | 33 +++++++++++++----
 internal/api/client_test.go                   | 25 ++++++++++++-
 internal/cli/auth.go                          |  6 ++--
 internal/cli/env_resolution_test.go           | 23 ++++++------
 internal/cli/main_test.go                     | 21 +++++++++++
 internal/cli/telemetry_test.go                |  4 +++
 internal/cli/testdata/golden/07-login.golden  |  4 +--
 .../cli/testdata/golden/zz-all-strings.golden |  2 +-
 internal/doctor/doctor.go                     | 30 ++++++++++++----
 internal/doctor/doctor_test.go                | 36 ++++++++++++++++++-
 11 files changed, 153 insertions(+), 33 deletions(-)
 create mode 100644 internal/cli/main_test.go

diff --git a/VERSION b/VERSION
index dbca4f3..61012ac 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.10.25
+0.10.26
diff --git a/internal/api/client.go b/internal/api/client.go
index e295189..88a241c 100644
--- a/internal/api/client.go
+++ b/internal/api/client.go
@@ -21,13 +21,22 @@ import (
 	"time"
 )
 
-// Backend environments (mirror CLIENT_ENV).
+// Backend environments (mirror the stage env var — $TRACEBLOC_ENV, legacy $CLIENT_ENV).
 const (
 	EnvDev  = "dev"
 	EnvStg  = "stg"
 	EnvProd = "prod"
 )
 
+// Stage-selecting environment variables (RFC-0076 settings-naming, backend#3391).
+// TRACEBLOC_ENV is the canonical name; CLIENT_ENV is the legacy alias, read as a
+// fallback so existing installs and $CLIENT_ENV exports keep working. Alias-first:
+// read new-or-old, never break a deployment. Legacy alias remove_by: 2026-12-31.
+const (
+	StageEnvVar       = "TRACEBLOC_ENV"
+	LegacyStageEnvVar = "CLIENT_ENV"
+)
+
 const defaultTimeout = 30 * time.Second
 
 // ── User-Agent: minimum-CLI-version handshake (RFC-0001 §13 / §14 R11 / C.1) ──
@@ -80,9 +89,9 @@ func (t userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error)
 	return t.base.RoundTrip(req)
 }
 
-// BaseURL maps a CLIENT_ENV value to the backend base URL — kept in lock-step
-// with the installer's `_backend_url` and client-runtime's CLIENT_ENV→backend
-// mapping. Unknown / empty → prod.
+// BaseURL maps a stage value to the backend base URL — kept in lock-step with the
+// installer's `_backend_url` and client-runtime's stage→backend mapping. Unknown /
+// empty → prod.
 func BaseURL(env string) string {
 	switch strings.ToLower(env) {
 	case EnvDev:
@@ -94,18 +103,28 @@ func BaseURL(env string) string {
 	}
 }
 
-// ResolveEnv picks the backend env: an explicit value (a --env flag) wins,
-// then $CLIENT_ENV, then prod.
+// ResolveEnv picks the backend env: an explicit value (a --env flag) wins, then
+// the stage env var — canonical $TRACEBLOC_ENV, else legacy $CLIENT_ENV — then prod.
 func ResolveEnv(explicit string) string {
 	if explicit != "" {
 		return strings.ToLower(explicit)
 	}
-	if e := os.Getenv("CLIENT_ENV"); e != "" {
+	if e := stageFromEnv(); e != "" {
 		return strings.ToLower(e)
 	}
 	return EnvProd
 }
 
+// stageFromEnv reads the deploy stage from the process environment, preferring the
+// canonical TRACEBLOC_ENV over the legacy CLIENT_ENV alias (remove_by 2026-12-31).
+// The single reader of both names, so the alias precedence lives in one place.
+func stageFromEnv() string {
+	if e := os.Getenv(StageEnvVar); e != "" {
+		return e
+	}
+	return os.Getenv(LegacyStageEnvVar)
+}
+
 // IsKnownEnv reports whether env is one of the recognized backends (dev/stg/prod,
 // case-insensitively). Callers that let a human PICK the env (e.g. `login`) use it
 // to reject a typo up front — BaseURL deliberately falls unknown values back to
diff --git a/internal/api/client_test.go b/internal/api/client_test.go
index 2a1f107..2d3a104 100644
--- a/internal/api/client_test.go
+++ b/internal/api/client_test.go
@@ -28,12 +28,14 @@ func TestBaseURL(t *testing.T) {
 }
 
 func TestResolveEnv(t *testing.T) {
+	// Isolate both stage vars: the canonical name and the legacy alias.
+	t.Setenv("TRACEBLOC_ENV", "")
 	t.Setenv("CLIENT_ENV", "stg")
 	if got := ResolveEnv("dev"); got != "dev" {
 		t.Errorf("explicit should win: got %q", got)
 	}
 	if got := ResolveEnv(""); got != "stg" {
-		t.Errorf("CLIENT_ENV should be used: got %q", got)
+		t.Errorf("legacy $CLIENT_ENV should be used as the fallback: got %q", got)
 	}
 	t.Setenv("CLIENT_ENV", "")
 	if got := ResolveEnv(""); got != "prod" {
@@ -41,6 +43,27 @@ func TestResolveEnv(t *testing.T) {
 	}
 }
 
+// TestResolveEnvStageAlias pins the RFC-0076 alias precedence (backend#3391): the
+// canonical $TRACEBLOC_ENV is preferred, the legacy $CLIENT_ENV is read only as a
+// fallback, and an explicit --env still beats both.
+func TestResolveEnvStageAlias(t *testing.T) {
+	// Canonical alone is honoured.
+	t.Setenv("TRACEBLOC_ENV", "dev")
+	t.Setenv("CLIENT_ENV", "")
+	if got := ResolveEnv(""); got != "dev" {
+		t.Errorf("canonical $TRACEBLOC_ENV should be used: got %q", got)
+	}
+	// Canonical wins over the legacy alias when both are set.
+	t.Setenv("CLIENT_ENV", "prod")
+	if got := ResolveEnv(""); got != "dev" {
+		t.Errorf("canonical $TRACEBLOC_ENV should beat legacy $CLIENT_ENV: got %q", got)
+	}
+	// Explicit --env still wins over both.
+	if got := ResolveEnv("stg"); got != "stg" {
+		t.Errorf("explicit --env should beat the environment: got %q", got)
+	}
+}
+
 func TestIsKnownEnv(t *testing.T) {
 	known := []string{"dev", "stg", "prod", "DEV", "Prod"} // case-insensitive
 	for _, env := range known {
diff --git a/internal/cli/auth.go b/internal/cli/auth.go
index 8adee68..42f7aa3 100644
--- a/internal/cli/auth.go
+++ b/internal/cli/auth.go
@@ -40,7 +40,7 @@ machine. Honors HTTP(S)_PROXY / NO_PROXY for corporate-proxy networks.`,
 		},
 	}
 	cmd.Flags().StringVar(&envFlag, "env", "",
-		"backend environment: dev|stg|prod (default: $CLIENT_ENV, then prod)")
+		"backend environment: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)")
 	return cmd
 }
 
@@ -65,7 +65,7 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
 	if !api.IsKnownEnv(env) {
 		return &exitError{code: exitFailure, err: fmt.Errorf(
 			"unknown backend environment %q — valid values are dev, stg, prod (default). "+
-				"Check --env / $CLIENT_ENV", env)}
+				"Check --env / $TRACEBLOC_ENV (or legacy $CLIENT_ENV)", env)}
 	}
 	client := newAPIClient(env)
 	p.Detailf("backend %s — requesting a device code …", client.BaseURL)
@@ -461,7 +461,7 @@ func newAuthStatusCmd() *cobra.Command {
 	cmd.Flags().BoolVar(&check, "check", false,
 		"exit 0 only if signed in with a backend-valid token, else 1; silent unless --verbose")
 	cmd.Flags().StringVar(&envFlag, "env", "",
-		"backend environment the check targets: dev|stg|prod (default: $CLIENT_ENV, then prod)")
+		"backend environment the check targets: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)")
 	return cmd
 }
 
diff --git a/internal/cli/env_resolution_test.go b/internal/cli/env_resolution_test.go
index 8449ec5..b049c47 100644
--- a/internal/cli/env_resolution_test.go
+++ b/internal/cli/env_resolution_test.go
@@ -428,9 +428,9 @@ func TestKnownSessionEnvGatesUnknownButKeepsKnown(t *testing.T) {
 // internal/ — i.e. blind exactly where a new site is most likely to land, in a
 // package written by someone who never reads internal/cli (Lukas on #551).
 var resolutionSites = map[string]string{
-	// The primitive: ResolveEnv is the --env/$CLIENT_ENV/prod chain, and the only
-	// os.Getenv("CLIENT_ENV") in the module.
-	"internal/api/client.go": "api.ResolveEnv — the primitive chain, and the only $CLIENT_ENV read",
+	// The primitive: ResolveEnv is the --env/$TRACEBLOC_ENV/legacy $CLIENT_ENV/prod
+	// chain, and the only os.Getenv of the stage var in the module.
+	"internal/api/client.go": "api.ResolveEnv — the primitive chain, and the only $TRACEBLOC_ENV/$CLIENT_ENV read",
 	// The --env FLAG, a different question: the env the human/installer NAMED,
 	// which login persists (the one cfg.CurrentEnv WRITE) and `auth status --check`
 	// validates against the session.
@@ -441,9 +441,10 @@ var resolutionSites = map[string]string{
 	// Storage. Profiles are keyed by the RAW stored string, so this layer must not
 	// normalise; it hands the raw value out and sessionEnv normalises it.
 	"internal/config/config.go": "the on-disk current_env field, its accessors, and the v1 migration",
-	// The CLUSTER's CLIENT_ENV, read off the jobs-manager Deployment — a
-	// deliberately different question from this CLI's session env.
-	"internal/doctor/doctor.go": "the cluster's own CLIENT_ENV, for the egress probe's target host",
+	// The CLUSTER's stage (chart-written $TRACEBLOC_ENV, legacy $CLIENT_ENV), read
+	// off the jobs-manager Deployment — a deliberately different question from this
+	// CLI's session env.
+	"internal/doctor/doctor.go": "the cluster's own stage var, for the egress probe's target host",
 	// internal/cli/telemetry.go is deliberately ABSENT, and the staleness check
 	// below is what keeps it that way: telemetryEnv/signedInEnv delegate the whole
 	// chain to sessionEnv and name no needle, so an entry for it would be inert —
@@ -455,10 +456,12 @@ var resolutionSites = map[string]string{
 // api.BaseURL/IsKnownEnv are pure mappings over an argument and are deliberately
 // absent — they resolve nothing.
 //
-// Deliberately BROAD (bare identifiers, and CLIENT_ENV unquoted so it matches
-// help text too): a false positive is a loud line in a diff, a false negative is
-// the bug this guard exists to catch. Fail closed.
-var envNeedles = []string{"CurrentEnv", "ResolveEnv", "CLIENT_ENV"}
+// Deliberately BROAD (bare identifiers, and the stage-var names unquoted so they
+// match help text too): a false positive is a loud line in a diff, a false negative
+// is the bug this guard exists to catch. Fail closed. TRACEBLOC_ENV is the RFC-0076
+// canonical stage var; CLIENT_ENV is its legacy alias (remove_by 2026-12-31) — both
+// are needles so a new read of EITHER name lands in the allowlist.
+var envNeedles = []string{"CurrentEnv", "ResolveEnv", "TRACEBLOC_ENV", "CLIENT_ENV"}
 
 // matchesAnyNeedle is THE matcher, called from both directions — the detection
 // sweep and the allowlist audit. One function on purpose: two copies of "does
diff --git a/internal/cli/main_test.go b/internal/cli/main_test.go
new file mode 100644
index 0000000..7274f96
--- /dev/null
+++ b/internal/cli/main_test.go
@@ -0,0 +1,21 @@
+package cli
+
+import (
+	"os"
+	"testing"
+)
+
+// TestMain clears BOTH stage-selecting env vars before the package's tests run, so
+// no test inherits a developer's or CI runner's ambient stage from the process
+// environment. This is load-bearing after RFC-0076 (backend#3391) made the reads
+// alias-first: api.ResolveEnv now consults the canonical $TRACEBLOC_ENV BEFORE the
+// legacy $CLIENT_ENV, so a test that pins the ambient stage by setting only one of
+// the two names would be silently overridden by an ambient value of the other. A
+// package-wide clean baseline fixes that for every current AND future site — the
+// same class-not-instance guarantee the env-resolution guard in this package is
+// built around — rather than relying on each test to neutralise both names.
+func TestMain(m *testing.M) {
+	_ = os.Unsetenv("TRACEBLOC_ENV")
+	_ = os.Unsetenv("CLIENT_ENV")
+	os.Exit(m.Run())
+}
diff --git a/internal/cli/telemetry_test.go b/internal/cli/telemetry_test.go
index 0505677..9194fd0 100644
--- a/internal/cli/telemetry_test.go
+++ b/internal/cli/telemetry_test.go
@@ -32,6 +32,10 @@ func testBuildInfo() BuildInfo {
 func isolateConfig(t *testing.T) {
 	t.Helper()
 	t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir())
+	// Pin the ambient stage to prod, clearing the canonical name too: it outranks
+	// the legacy $CLIENT_ENV set below (api.stageFromEnv), so leaving it set would
+	// let it override this pin.
+	t.Setenv("TRACEBLOC_ENV", "")
 	t.Setenv("CLIENT_ENV", api.EnvProd)
 }
 
diff --git a/internal/cli/testdata/golden/07-login.golden b/internal/cli/testdata/golden/07-login.golden
index d08e935..db8da7e 100644
--- a/internal/cli/testdata/golden/07-login.golden
+++ b/internal/cli/testdata/golden/07-login.golden
@@ -22,7 +22,7 @@ Usage:
   tracebloc login [flags]
 
 Flags:
-      --env string   backend environment: dev|stg|prod (default: $CLIENT_ENV, then prod)
+      --env string   backend environment: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)
   -h, --help         help for login
 
 Global Flags:
@@ -50,7 +50,7 @@ Usage:
 
 Flags:
       --check        exit 0 only if signed in with a backend-valid token, else 1; silent unless --verbose
-      --env string   backend environment the check targets: dev|stg|prod (default: $CLIENT_ENV, then prod)
+      --env string   backend environment the check targets: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)
   -h, --help         help for status
 
 Global Flags:
diff --git a/internal/cli/testdata/golden/zz-all-strings.golden b/internal/cli/testdata/golden/zz-all-strings.golden
index af95723..e4ccd85 100644
--- a/internal/cli/testdata/golden/zz-all-strings.golden
+++ b/internal/cli/testdata/golden/zz-all-strings.golden
@@ -809,7 +809,7 @@ screen. %s/%d are runtime placeholders.
 "tracebloc-doctor-%s.txt"
 "tracebloc-stage-%s-%s"
 "unavailable"
-"unknown backend environment %q — valid values are dev, stg, prod (default). Check --env / $CLIENT_ENV"
+"unknown backend environment %q — valid values are dev, stg, prod (default). Check --env / $TRACEBLOC_ENV (or legacy $CLIENT_ENV)"
 "unknown command %q for %q"
 "unparseable MemTotal %q: %w"
 "unparseable NCPU %q: %w"
diff --git a/internal/doctor/doctor.go b/internal/doctor/doctor.go
index e2670cb..6ed3a4f 100644
--- a/internal/doctor/doctor.go
+++ b/internal/doctor/doctor.go
@@ -570,7 +570,7 @@ func checkProxy(env map[string]string) Result {
 // the backend at all.
 func checkBackendEgress(ctx context.Context, env map[string]string, probe func(context.Context, string) error) Result {
 	const name = "Backend egress (from this machine)"
-	host := backendHost(env["CLIENT_ENV"])
+	host := backendHost(stageFromClusterSpec(env))
 	url := "https://" + host + "/"
 	if err := probe(ctx, url); err != nil {
 		return Result{
@@ -583,20 +583,36 @@ func checkBackendEgress(ctx context.Context, env map[string]string, probe func(c
 	return Result{Name: name, Status: StatusOK, Detail: host + " reachable"}
 }
 
-// backendHost maps CLIENT_ENV to the backend API host, mirroring the edge
+// stageFromClusterSpec picks the deploy stage out of the jobs-manager container's
+// env, preferring the canonical TRACEBLOC_ENV over the legacy CLIENT_ENV alias.
+// These keys are written by the edge CHART (this CLI is a consumer here, not the
+// owner): reading both lets the chart adopt the canonical name on its own S3-edge
+// timeline without a flag-day, and today — while the chart still writes CLIENT_ENV
+// — the fallback keeps behaviour identical. Legacy alias remove_by: 2026-12-31.
+// The literals are spelled out (not api.StageEnvVar) both because these are the
+// chart's key names, not this CLI's process-env names, and so the env-resolution
+// guard still sees this file as a sanctioned stage-var read site.
+func stageFromClusterSpec(env map[string]string) string {
+	if v := env["TRACEBLOC_ENV"]; v != "" {
+		return v
+	}
+	return env["CLIENT_ENV"]
+}
+
+// backendHost maps a stage value to the backend API host, mirroring the edge
 // runtime's own mapping (controller.py). Unset/unknown defaults to prod, the
-// chart's CLIENT_ENV default.
+// chart's stage default.
 //
 // DERIVED FROM api.BaseURL, not restated. The env→host mapping used to be a
 // second copy of BaseURL's switch living in this package, which is how the two
 // drift: the same three hosts written down twice, with nothing that fails when
 // only one of them is edited. api.BaseURL already lower-cases, so TrimSpace is
-// the only normalisation this adds — a CLIENT_ENV read off a container spec can
+// the only normalisation this adds — a stage value read off a container spec can
 // carry surrounding whitespace that a --env flag cannot.
 //
-// The input is the CLUSTER's CLIENT_ENV (read off the jobs-manager Deployment),
-// not this CLI's session env — a deliberately different question, which is why
-// this takes a string rather than calling into the session resolution.
+// The input is the CLUSTER's stage (read off the jobs-manager Deployment), not
+// this CLI's session env — a deliberately different question, which is why this
+// takes a string rather than calling into the session resolution.
 func backendHost(clientEnv string) string {
 	u, err := url.Parse(api.BaseURL(strings.TrimSpace(clientEnv)))
 	if err != nil || u.Host == "" {
diff --git a/internal/doctor/doctor_test.go b/internal/doctor/doctor_test.go
index ec9de8b..490e13c 100644
--- a/internal/doctor/doctor_test.go
+++ b/internal/doctor/doctor_test.go
@@ -16,6 +16,7 @@ import (
 	"k8s.io/client-go/kubernetes/fake"
 	k8stesting "k8s.io/client-go/testing"
 
+	"github.com/tracebloc/cli/internal/api"
 	"github.com/tracebloc/cli/internal/cluster"
 )
 
@@ -404,13 +405,46 @@ func TestCheckBackendEgress(t *testing.T) {
 	failProbe := func(context.Context, string) error { return errors.New("dns failure") }
 
 	if r := checkBackendEgress(bg(), map[string]string{"CLIENT_ENV": "dev"}, okProbe); r.Status != StatusOK || !strings.Contains(r.Detail, "dev-api.tracebloc.io") {
-		t.Fatalf("reachable dev => %v / %q", r.Status, r.Detail)
+		t.Fatalf("reachable dev (legacy CLIENT_ENV) => %v / %q", r.Status, r.Detail)
+	}
+	// Canonical stage key on the cluster spec resolves the same host (RFC-0076).
+	if r := checkBackendEgress(bg(), map[string]string{"TRACEBLOC_ENV": "dev"}, okProbe); r.Status != StatusOK || !strings.Contains(r.Detail, "dev-api.tracebloc.io") {
+		t.Fatalf("reachable dev (canonical TRACEBLOC_ENV) => %v / %q", r.Status, r.Detail)
 	}
 	if r := checkBackendEgress(bg(), map[string]string{}, failProbe); r.Status != StatusFail || !strings.Contains(r.Detail, "api.tracebloc.io") {
 		t.Fatalf("unreachable default => %v / %q", r.Status, r.Detail)
 	}
 }
 
+// TestStageFromClusterSpec pins the alias-first read of the chart-written stage
+// key: canonical TRACEBLOC_ENV preferred, legacy CLIENT_ENV as the fallback.
+func TestStageFromClusterSpec(t *testing.T) {
+	// stageFromClusterSpec reads the key LITERALS (spelled out for the cli-package
+	// env-resolution guard), so nothing compile-couples them to the api consts. Pin
+	// the two names in lock-step here: if api's canonical/legacy stage-var names ever
+	// change, this fails instead of the doctor silently reading a stale key off the
+	// jobs-manager spec and probing the wrong host.
+	if api.StageEnvVar != "TRACEBLOC_ENV" || api.LegacyStageEnvVar != "CLIENT_ENV" {
+		t.Fatalf("stage-var names drifted from the literals stageFromClusterSpec reads: "+
+			"canonical api.StageEnvVar=%q, legacy api.LegacyStageEnvVar=%q", api.StageEnvVar, api.LegacyStageEnvVar)
+	}
+	tests := []struct {
+		name string
+		env  map[string]string
+		want string
+	}{
+		{"canonical only", map[string]string{"TRACEBLOC_ENV": "dev"}, "dev"},
+		{"legacy only", map[string]string{"CLIENT_ENV": "stg"}, "stg"},
+		{"canonical wins", map[string]string{"TRACEBLOC_ENV": "dev", "CLIENT_ENV": "prod"}, "dev"},
+		{"neither", map[string]string{}, ""},
+	}
+	for _, tc := range tests {
+		if got := stageFromClusterSpec(tc.env); got != tc.want {
+			t.Errorf("%s: stageFromClusterSpec(%v) = %q, want %q", tc.name, tc.env, got, tc.want)
+		}
+	}
+}
+
 func TestBackendHost(t *testing.T) {
 	tests := map[string]string{
 		"dev":   "dev-api.tracebloc.io",

From d75e265431364c0721789703353549237739760f Mon Sep 17 00:00:00 2001
From: "Asad Iqbal (Saadi)" 
Date: Fri, 11 Sep 2026 15:54:31 +0500
Subject: [PATCH 3/4] fix(login): reuse a valid session instead of always
 starting a device flow (#651) (#658)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix(login): reuse a valid session instead of always starting a device flow (cli#651)

`tracebloc login` went straight to a device code even when the machine
already held a valid session for the target env. On a headless host that
is a dead end rather than an inconvenience: the credentials are on disk,
but the command insists on a browser approval it has no way to complete —
turning "re-run login to be safe" into a hard stop for any script or
runbook.

login now checks the session it already has before asking for one:

- A session for the target env that the backend accepts ends the command
  at exit 0 with "Already signed in as ". The session is
  confirmed with a live WhoAmI rather than trusted off disk — a revoked
  token is still a token on disk.
- `--force` is the opt-out (switching accounts, replacing a session
  believed stale) and skips the short-circuit entirely, including the
  probe. Same sense as `delete --force`: proceed despite the state that
  would otherwise stop you.
- Every fall-through says WHY first, and only claims what it can tell
  apart: a LOCAL expires_at that has passed is named to the second (and
  is not presented to the backend), a 401/403 is reported as rejected,
  and anything else — DNS, a 5xx — is "couldn't check", not a verdict on
  the session. A 426 surfaces the upgrade instruction and starts no flow,
  since a fresh flow would hit the same version floor.

"A session for this env" is resolved two ways, so credentials already on
disk aren't stranded: the current session when sessionEnv resolves it to
the target (the same predicate `auth status --check` uses, so login and
the installer's probe cannot disagree), else that env's own profile — a
machine on prod can hold a live dev token, and `login --env dev` adopts
it and switches current_env. The profile is written back under the key it
was found under, so a v1-migrated `"Dev"` config doesn't gain a second,
lower-cased profile beside the real one.

Tests cover each arm by asserting whether /device/code was requested at
all — the browser demand is the behaviour that matters. Goldens
regenerated for the new --force flag and copy.

Closes tracebloc/cli#651

Co-Authored-By: Claude Opus 5 

* chore(release): bump VERSION to 0.10.26 for the login short-circuit (cli#651)

version-bump-gate failed on the previous head: VERSION still read 0.10.25,
v0.10.25 is already released, and this PR changes a published file
(internal/cli/auth.go, matching `internal/*`). The release train cuts the
tag from this file and never bumps it, so leaving it stale doesn't fail
here — it fails the next prod hop, days later, on somebody else
(backend#1561).

0.10.26 is free: v0.10.25 is the highest released final version and no
v0.10.26 tag exists. Patch, not minor — this ships one bug fix, and the
new `login --force` flag is additive with no change to any existing
invocation.

(Open PR #657 also touches VERSION, but bumps 0.10.24 -> 0.10.25, which is
already released — its gate is red for the same reason and it needs a
rebase. It does not claim 0.10.26.)

Co-Authored-By: Claude Opus 5 

* fix(login): find raw-keyed profiles, and exit 130 on a cancelled probe (cli#651)

Two Bugbot findings on PR #658, both real.

1. storedSessionFor's second arm indexed cfg.Profiles with the ALREADY
   NORMALISED target env, so a live token written under a raw key such as
   `"Dev"` (config.migrateV1 stores a v1 `env` verbatim) went unseen the
   moment that profile stopped being the current one. `login --env dev`
   then ran a device flow and saved a SECOND profile under `"dev"`,
   stranding a perfectly good session beside it — on exactly the headless
   host this issue is about.

   The existing raw-key test could not see it: it keeps `"Dev"` CURRENT,
   which arm 1 catches before the map lookup is reached. The gap only
   opens after a `login --env` elsewhere has moved current_env.

   Lookup now folds the map's own KEYS (new profileKeyed). Exact match
   wins; the fold is a tie-break scanned in sorted order, so a config
   holding both `"Dev"` and `"dev"` cannot answer differently run to run
   on Go's randomised map iteration. The trim+lower-case is extracted
   from sessionEnv as normalizeEnv and shared, rather than hand-rolled a
   second time — a second copy is how the keys stop matching in the first
   place.

2. A cancelled context surfaces on the WhoAmI call as a plain error, so
   Ctrl-C during the new probe landed in the "couldn't check" arm: it
   printed "signing in again" and then failed RequestDeviceCode with exit
   1, where every other interrupt in login exits 130 silently. Guarded on
   ctx.Err() before the classification — the same guard, for the same
   reason, as pollForToken's.

Tests: the not-current raw-key case (asserts no flow, no duplicate
profile, current_env set to the FOUND key), a determinism test running
profileKeyed 50x over a config with three case variants, and a cancelled
probe asserting exit 130, silence, and zero device codes.

Co-Authored-By: Claude Opus 5 

* refactor(login): one shared WhoAmI classifier for both session probes (cli#651)

Review on PR #658: reuseStoredSession's 426 / 401-403 / everything-else
classification of a WhoAmI failure duplicated the identical block in
runAuthCheck a few dozen lines down. The duplication is one this PR
introduced, so it is fixed here rather than left for a follow-up —
"fix the class, not the instance".

classifyWhoAmIError returns a named whoAmIVerdict (whoAmIUnverified /
whoAmIRejected / whoAmIUpgradeRequired) plus the *api.UpgradeRequiredError,
so the caller surfaces the server's own version floor rather than a
paraphrase. The three arms are deliberately not collapsible: only
whoAmIRejected is a statement about the credential. A 5xx folded into it
would tell someone to re-authenticate during an outage, and a 426 folded
into it would send them to a browser step that cannot lift a version floor.

The COPY stays at the call sites. The two commands answer different
questions — "should I start a device flow?" vs "what is this exit code?" —
and say so in different words; only the classification is shared. No
user-facing string changed, and the goldens confirm it (regenerated, no
diff).

Test: a table over 401/403/426/500/404/429, a transport error, a cancelled
context, and wrapped 401/426 — wrapped because both call sites receive the
error through the api client's own fmt.Errorf wrapping, so matching the
concrete type alone would silently demote every real verdict to
"unverified".

Co-Authored-By: Claude Opus 5 

---------

Co-authored-by: Claude Opus 5 
---
 internal/cli/auth.go                          | 235 ++++++++-
 internal/cli/auth_test.go                     | 464 ++++++++++++++++++
 internal/cli/client.go                        |  11 +-
 internal/cli/testdata/golden/07-login.golden  |   7 +
 .../cli/testdata/golden/zz-all-strings.golden |   7 +
 5 files changed, 714 insertions(+), 10 deletions(-)

diff --git a/internal/cli/auth.go b/internal/cli/auth.go
index 42f7aa3..9f6c068 100644
--- a/internal/cli/auth.go
+++ b/internal/cli/auth.go
@@ -6,6 +6,7 @@ import (
 	"fmt"
 	"net/http"
 	"os"
+	"sort"
 	"time"
 
 	"github.com/spf13/cobra"
@@ -23,6 +24,7 @@ import (
 // backend doesn't support browser sign-in yet.
 func newLoginCmd() *cobra.Command {
 	var envFlag string
+	var force bool
 	cmd := &cobra.Command{
 		Use:         "login",
 		Annotations: runtimeClassFor(classBackend),
@@ -32,15 +34,23 @@ on any device (your laptop or phone), sign in the way you already do
 (password, Google, or GitHub), and approve the code. The CLI stores a
 user token in ~/.tracebloc (mode 0600).
 
+Already signed in to this backend? login says so and exits 0 without a
+browser step — so re-running it is safe in a script or a runbook, and a
+headless box is never asked to approve a code it already has. Pass
+--force to re-authenticate anyway: switching accounts, or replacing a
+session you believe is stale.
+
 Works on a headless / SSH box — the browser and the CLI need not share a
 machine. Honors HTTP(S)_PROXY / NO_PROXY for corporate-proxy networks.`,
 		Args: cobra.NoArgs,
 		RunE: func(cmd *cobra.Command, _ []string) error {
-			return runLogin(cmd.Context(), printerFor(cmd), envFlag)
+			return runLogin(cmd.Context(), printerFor(cmd), envFlag, force)
 		},
 	}
 	cmd.Flags().StringVar(&envFlag, "env", "",
 		"backend environment: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)")
+	cmd.Flags().BoolVar(&force, "force", false,
+		"start a new device flow even when this machine already holds a valid session")
 	return cmd
 }
 
@@ -52,7 +62,7 @@ var (
 	pollAfter    = time.After
 )
 
-func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
+func runLogin(ctx context.Context, p *ui.Printer, envFlag string, force bool) error {
 	cfg, err := config.Load()
 	if err != nil {
 		return &exitError{code: exitFailure, err: err}
@@ -67,6 +77,20 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
 			"unknown backend environment %q — valid values are dev, stg, prod (default). "+
 				"Check --env / $TRACEBLOC_ENV (or legacy $CLIENT_ENV)", env)}
 	}
+	// Before asking for a browser approval, USE the credentials already on disk
+	// (cli#651). Without this, "re-run login to be safe" — a reasonable thing for
+	// a script or a runbook to do — is a hard stop on any host without a browser,
+	// even though the session is valid and every other command would accept it.
+	if !force {
+		reused, err := reuseStoredSession(ctx, p, cfg, env)
+		if err != nil {
+			return err
+		}
+		if reused {
+			return nil
+		}
+	}
+
 	client := newAPIClient(env)
 	p.Detailf("backend %s — requesting a device code …", client.BaseURL)
 
@@ -132,6 +156,203 @@ func runLogin(ctx context.Context, p *ui.Printer, envFlag string) error {
 	return nil
 }
 
+// storedSessionFor returns the profile KEY and the profile of the session this
+// machine already holds for env, or ("", nil) when it holds none.
+//
+// Two arms, because "a session for env" has two shapes on disk and answering
+// only the first would leave the credentials for the second unused:
+//
+//   - the CURRENT session, when it RESOLVES to env. This is the same predicate
+//     `auth status --check` uses (sessionEnv, not the raw cfg.CurrentEnv), so
+//     login's short-circuit and the installer's probe cannot disagree about what
+//     "signed in to this env" means.
+//   - failing that, env's OWN profile. Profiles are per-env (R10), so a machine
+//     signed in to prod can still hold a live dev token; `login --env dev` must
+//     find it rather than run a flow for a credential already on disk.
+//
+// The KEY comes back with the profile because Profiles is keyed on the RAW env
+// string — a v1-migrated config stores `"Dev"` verbatim (config.migrateV1) while
+// sessionEnv normalises for the comparison. Writing the reused session back
+// under the key it was FOUND under is what keeps a second, lower-cased profile
+// from being minted alongside it.
+func storedSessionFor(cfg *config.Config, env string) (string, *config.Profile) {
+	if cfg.SignedIn() && sessionEnv(cfg) == env {
+		return cfg.CurrentEnv, cfg.Current()
+	}
+	return profileKeyed(cfg, env)
+}
+
+// profileKeyed finds env's own profile by FOLDING the map's keys, not by
+// indexing with the already-normalised target (Bugbot, PR #658).
+//
+// A plain `cfg.Profiles[env]` only matches a key that is already lower-cased, so
+// a live token written under `"Dev"` went unseen the moment that profile stopped
+// being the current one — and the flow that followed saved a SECOND profile under
+// `"dev"`, leaving the original session stranded beside it on exactly the headless
+// host cli#651 is about. Arm 1 of storedSessionFor hid this: it catches the raw
+// key while it is current, so the gap only opens after a `login --env` elsewhere.
+//
+// Exact match wins, and the fold is a tie-break scanned in sorted order — with
+// both `"Dev"` and `"dev"` on disk the answer must not depend on Go's randomised
+// map iteration.
+func profileKeyed(cfg *config.Config, env string) (string, *config.Profile) {
+	if p := cfg.Profiles[env]; p != nil && p.Token != "" {
+		return env, p
+	}
+	keys := make([]string, 0, len(cfg.Profiles))
+	for k := range cfg.Profiles {
+		keys = append(keys, k)
+	}
+	sort.Strings(keys)
+	for _, k := range keys {
+		if p := cfg.Profiles[k]; p != nil && p.Token != "" && normalizeEnv(k) == env {
+			return k, p
+		}
+	}
+	return "", nil
+}
+
+// sessionExpired reports whether a stored profile's own recorded expiry has
+// already passed, and renders it for the message.
+//
+// expires_at is "when known" (config.Profile) and the device grant does not
+// return one today, so this is usually absent and the verdict comes from the
+// backend instead. That split is exactly why the two copy paths differ: a LOCAL
+// expiry we can name to the second, and a 401 we can only report as "rejected",
+// because the backend returns the same status for a token that expired and one
+// that was revoked. Naming a cause we cannot distinguish would be worse than
+// reporting the one fact we have.
+//
+// An unparseable timestamp is NOT treated as expired: the backend, not a
+// malformed config field, gets to invalidate a session.
+func sessionExpired(prof *config.Profile) (bool, string) {
+	if prof.ExpiresAt == "" {
+		return false, ""
+	}
+	t, err := time.Parse(time.RFC3339, prof.ExpiresAt)
+	if err != nil || time.Now().Before(t) {
+		return false, ""
+	}
+	return true, t.Format(time.RFC3339)
+}
+
+// whoAmIVerdict is what a FAILED WhoAmI says about the stored session. The three
+// arms are deliberately not collapsible: only one of them is a statement about
+// the credential.
+type whoAmIVerdict int
+
+const (
+	// whoAmIUnverified — no verdict was ever reached: DNS, a refused connection,
+	// a 5xx. NOT evidence the session is bad, and must never be reported as if it
+	// were: telling someone to re-authenticate during an outage sends them to a
+	// browser step that cannot help.
+	whoAmIUnverified whoAmIVerdict = iota
+	// whoAmIRejected — the backend refused the credential (401/403). The one arm
+	// where signing in again is the answer.
+	whoAmIRejected
+	// whoAmIUpgradeRequired — a 426: this CLI is below the server's version floor.
+	// Says nothing about the session, and no amount of re-authenticating fixes it.
+	whoAmIUpgradeRequired
+)
+
+// classifyWhoAmIError turns a failed WhoAmI into that verdict, returning the
+// *api.UpgradeRequiredError alongside it so the caller can surface the server's
+// own version floor rather than a paraphrase.
+//
+// Shared because both places that probe a stored session — login's short-circuit
+// and `auth status --check` — have to draw the SAME three-way distinction, and
+// two hand-written copies of it drift the first time a fourth case appears
+// (review on PR #658). The copy stays at the call sites: the two commands answer
+// different questions ("should I start a flow?" vs "what is this exit code?") and
+// say so in different words.
+func classifyWhoAmIError(err error) (whoAmIVerdict, *api.UpgradeRequiredError) {
+	var ue *api.UpgradeRequiredError
+	if errors.As(err, &ue) {
+		return whoAmIUpgradeRequired, ue
+	}
+	var ae *api.APIError
+	if errors.As(err, &ae) &&
+		(ae.StatusCode == http.StatusUnauthorized || ae.StatusCode == http.StatusForbidden) {
+		return whoAmIRejected, nil
+	}
+	return whoAmIUnverified, nil
+}
+
+// reuseStoredSession is login's "you are already signed in" short-circuit
+// (cli#651). It reports whether login is DONE: true means the machine holds a
+// session for env that the backend just accepted, and there is nothing to sign
+// in to. False means fall through to the device flow — every such path first
+// says WHY, so a user who expected the short-circuit learns whether their
+// session expired, was rejected, or simply couldn't be checked.
+//
+// The session is confirmed against the backend rather than trusted off disk: a
+// token that has been revoked is still a token on disk, and reporting it as a
+// live session would send the user into the next command to discover otherwise.
+// When the backend can't be reached we fall through to the flow, which is what
+// login does today — the flow surfaces the network failure in its own words.
+func reuseStoredSession(ctx context.Context, p *ui.Printer, cfg *config.Config, env string) (bool, error) {
+	key, prof := storedSessionFor(cfg, env)
+	if prof == nil {
+		return false, nil
+	}
+	if expired, at := sessionExpired(prof); expired {
+		p.Hintf("The session saved on this machine expired at %s — signing in again.", at)
+		return false, nil
+	}
+
+	client := newAPIClient(env)
+	client.Token = prof.Token
+	p.Detailf("backend %s — checking the session already on this machine …", client.BaseURL)
+	id, err := client.WhoAmI(ctx)
+	if err != nil {
+		// Ctrl-C landing during the probe is the OPERATOR, not an unverifiable
+		// session: fall through and we print "signing in again", then fail
+		// RequestDeviceCode with exit 1 — where every other interrupt in login exits
+		// 130 silently. Checked before the classification below because a cancelled
+		// context surfaces on the HTTP call as a plain error, which would otherwise
+		// land in the "couldn't check" arm (Bugbot, PR #658; same guard, same
+		// reason, as pollForToken's).
+		if ctx.Err() != nil {
+			return false, &exitError{code: exitInterrupted}
+		}
+		switch verdict, ue := classifyWhoAmIError(err); verdict {
+		case whoAmIUpgradeRequired:
+			// Not a verdict on the session, and a fresh device flow would hit the same
+			// floor — surface the upgrade instruction instead of burning a browser
+			// approval on it.
+			return false, &exitError{code: exitFailure, err: ue}
+		case whoAmIRejected:
+			p.Hintf("The backend rejected the session saved on this machine — it expired or was revoked. Signing in again.")
+		default: // whoAmIUnverified
+			p.Hintf("Couldn't check the session saved on this machine (%v) — signing in again.", err)
+		}
+		return false, nil
+	}
+
+	// The session is live. Adopt it as the active one: `login --env dev` from a
+	// prod session is a request to SWITCH, and answering "already signed in"
+	// without moving current_env would leave every following command on prod.
+	// Written under the key the profile was found under, never a re-derived one.
+	cfg.CurrentEnv = key
+	prof.Email, prof.FirstName = id.Email, id.FirstName
+	if err := cfg.Save(); err != nil {
+		return false, &exitError{code: exitFailure, err: err}
+	}
+	if id.Email != "" {
+		p.Successf("Already signed in as %s.", id.Email)
+	} else {
+		p.Successf("Already signed in.")
+	}
+	// Visible, not demoted to Detailf, and deliberately NOT routed through
+	// withSignInAdvice: this is the actionable half of an outcome the user did not
+	// ask for. Someone who typed `login` to switch accounts needs the next step
+	// here, and unlike the advice withSignInAdvice guards, it contradicts nothing
+	// the installer prints — the installer reaches this line only on its success
+	// path, where its own next step is to carry on provisioning, not to re-auth.
+	p.Hintf("Run `tracebloc login --force` to sign in again — switching accounts, or replacing a session you believe is stale.")
+	return true, nil
+}
+
 // pollDisposition is what the poll loop does with a failed PollToken call.
 type pollDisposition int
 
@@ -514,16 +735,12 @@ func runAuthCheck(ctx context.Context, p *ui.Printer, envFlag string) error {
 		// A 426 means the CLI is too old, not that the session is invalid — surface
 		// the upgrade instruction (non-silent, so it shows even without --verbose)
 		// instead of the "re-login" advice, which wouldn't help.
-		var ue *api.UpgradeRequiredError
-		if errors.As(err, &ue) {
+		verdict, ue := classifyWhoAmIError(err)
+		if verdict == whoAmIUpgradeRequired {
 			return &exitError{code: exitFailure, err: ue}
 		}
 		if p.Verbose() {
-			// Only a 401/403 is genuinely a rejected token (where re-login helps); a
-			// network/DNS/5xx failure means we couldn't verify, not that the session
-			// is invalid — don't send the user to re-login for an outage.
-			var apiErr *api.APIError
-			if errors.As(err, &apiErr) && (apiErr.StatusCode == http.StatusUnauthorized || apiErr.StatusCode == http.StatusForbidden) {
+			if verdict == whoAmIRejected {
 				p.Hintf("Signed-in token was rejected by the backend — run `tracebloc login`.")
 			} else {
 				p.Hintf("Couldn't verify your session with the backend (%v).", err)
diff --git a/internal/cli/auth_test.go b/internal/cli/auth_test.go
index f4b9f31..f2cffd7 100644
--- a/internal/cli/auth_test.go
+++ b/internal/cli/auth_test.go
@@ -5,6 +5,7 @@ import (
 	"context"
 	"errors"
 	"fmt"
+	"io"
 	"net"
 	"net/http"
 	"net/http/httptest"
@@ -14,6 +15,7 @@ import (
 
 	"github.com/tracebloc/cli/internal/api"
 	"github.com/tracebloc/cli/internal/config"
+	"github.com/tracebloc/cli/internal/ui"
 )
 
 // withTestBackend points the login command at an httptest server (via the
@@ -851,3 +853,465 @@ func TestLogin_ClearsStaleIdentityOnWhoAmIFailure(t *testing.T) {
 		t.Errorf("stale identity leaked: FirstName=%q Email=%q (want both cleared)", prof.FirstName, prof.Email)
 	}
 }
+
+// ── login's "already signed in" short-circuit (cli#651) ────────────────────────
+//
+// The bug: `login` went straight to a device code even when the machine already
+// held a valid session, which on a headless host is a dead end rather than an
+// inconvenience — the credentials are on disk, but the command insists on a
+// browser approval it has no way to complete.
+//
+// Every test below asserts on whether /device/code was requested, because THAT
+// is the behaviour that matters: the copy is secondary to whether a browser step
+// was demanded.
+
+// loginBackend serves the device flow + /userinfo/, counting device-code
+// requests, and lets a test decide what /userinfo/ says about the session that
+// is already on disk.
+func loginBackend(t *testing.T, userinfo http.HandlerFunc) *int {
+	t.Helper()
+	var codes int
+	withTestBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/device/code":
+			codes++
+			_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"WDJB-MJHT","verification_uri":"https://x/activate","expires_in":600,"interval":5}`))
+		case "/device/token":
+			_, _ = w.Write([]byte(`{"token":"fresh_tok"}`))
+		case "/userinfo/":
+			userinfo(w, r)
+		default:
+			t.Errorf("unexpected request path %s", r.URL.Path)
+		}
+	})
+	return &codes
+}
+
+// okUserinfo accepts whatever token is presented.
+func okUserinfo(w http.ResponseWriter, _ *http.Request) {
+	_, _ = w.Write([]byte(`{"email":"ds@co","first_name":"Dana","account":"Acme"}`))
+}
+
+// TestLogin_ReusesValidSession is the issue's reproduction: a second `login`
+// against a live session must exit 0 without requesting a device code.
+func TestLogin_ReusesValidSession(t *testing.T) {
+	codes := loginBackend(t, okUserinfo)
+	saveSignedIn(t, "live_tok") // CurrentEnv=dev
+
+	out, err := runCmd(t, "login", "--env", "dev")
+	if err != nil {
+		t.Fatalf("login over a valid session should exit 0, got: %v", err)
+	}
+	if *codes != 0 {
+		t.Errorf("requested %d device codes; a valid session must not start a flow", *codes)
+	}
+	if !strings.Contains(out, "Already signed in as ds@co") {
+		t.Errorf("expected the already-signed-in line, got:\n%s", out)
+	}
+	if !strings.Contains(out, "--force") {
+		t.Errorf("expected the --force opt-out to be named, got:\n%s", out)
+	}
+	// The session is kept, not replaced by the flow's token.
+	cfg, _ := config.Load()
+	if got := cfg.Current().Token; got != "live_tok" {
+		t.Errorf("token = %q, want the existing live_tok", got)
+	}
+}
+
+// TestLogin_ForceStartsAFlowOverAValidSession: --force is the opt-out for
+// switching accounts or replacing a session believed stale, so it must skip the
+// short-circuit entirely — including the probe.
+func TestLogin_ForceStartsAFlowOverAValidSession(t *testing.T) {
+	codes := loginBackend(t, okUserinfo)
+	saveSignedIn(t, "live_tok")
+
+	out, err := runCmd(t, "login", "--env", "dev", "--force")
+	if err != nil {
+		t.Fatalf("login --force: %v", err)
+	}
+	if *codes != 1 {
+		t.Errorf("requested %d device codes, want 1 under --force", *codes)
+	}
+	if strings.Contains(out, "Already signed in") {
+		t.Errorf("--force must not short-circuit, got:\n%s", out)
+	}
+	cfg, _ := config.Load()
+	if got := cfg.Current().Token; got != "fresh_tok" {
+		t.Errorf("token = %q, want the re-authenticated fresh_tok", got)
+	}
+}
+
+// TestLogin_RejectedSessionSaysSoThenSignsIn: a 401 is the backend REJECTING the
+// stored credential — name that, then run the flow the user came for.
+func TestLogin_RejectedSessionSaysSoThenSignsIn(t *testing.T) {
+	codes := loginBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("Authorization") == "Bearer stale_tok" {
+			w.WriteHeader(http.StatusUnauthorized)
+			return
+		}
+		okUserinfo(w, r)
+	})
+	saveSignedIn(t, "stale_tok")
+
+	out, err := runCmd(t, "login", "--env", "dev")
+	if err != nil {
+		t.Fatalf("login after a rejected session: %v", err)
+	}
+	if *codes != 1 {
+		t.Errorf("requested %d device codes, want 1 after a rejected session", *codes)
+	}
+	if !strings.Contains(out, "rejected the session saved on this machine") {
+		t.Errorf("expected the rejection to be named before the new flow, got:\n%s", out)
+	}
+	cfg, _ := config.Load()
+	if got := cfg.Current().Token; got != "fresh_tok" {
+		t.Errorf("token = %q, want fresh_tok", got)
+	}
+}
+
+// TestLogin_UnverifiableSessionIsNotCalledRejected: a 5xx means we COULDN'T
+// CHECK, which is a different situation from a rejected credential — reporting
+// it as a rejection would tell the user their session is bad when the backend is
+// simply down. (Same distinction runAuthCheck draws; cli#651 asks login to draw
+// it too.)
+func TestLogin_UnverifiableSessionIsNotCalledRejected(t *testing.T) {
+	codes := loginBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("Authorization") == "Bearer live_tok" {
+			w.WriteHeader(http.StatusInternalServerError) // reachable but erroring
+			return
+		}
+		okUserinfo(w, r)
+	})
+	saveSignedIn(t, "live_tok")
+
+	out, err := runCmd(t, "login", "--env", "dev")
+	if err != nil {
+		t.Fatalf("login after an unverifiable session: %v", err)
+	}
+	if *codes != 1 {
+		t.Errorf("requested %d device codes, want 1 when the session can't be checked", *codes)
+	}
+	if !strings.Contains(out, "Couldn't check the session saved on this machine") {
+		t.Errorf("expected a 'couldn't check' line, got:\n%s", out)
+	}
+	if strings.Contains(out, "rejected") {
+		t.Errorf("a 500 must not be reported as a rejected session, got:\n%s", out)
+	}
+}
+
+// TestLogin_LocallyExpiredSessionNamesTheExpiryWithoutProbing: when the stored
+// profile carries an expires_at that has passed, we know the answer locally —
+// say "expired" (the one cause we can actually distinguish) and don't spend a
+// round-trip presenting a credential we know is dead.
+func TestLogin_LocallyExpiredSessionNamesTheExpiryWithoutProbing(t *testing.T) {
+	// Only the OLD token's presentation counts: login's own post-flow
+	// confirmation hits /userinfo/ too, with the token it just obtained.
+	presented := false
+	codes := loginBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("Authorization") == "Bearer old_tok" {
+			presented = true
+		}
+		okUserinfo(w, r)
+	})
+	past := time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)
+	if err := (&config.Config{CurrentEnv: "dev", Profiles: map[string]*config.Profile{
+		"dev": {Token: "old_tok", Email: "ds@co", ExpiresAt: past},
+	}}).Save(); err != nil {
+		t.Fatal(err)
+	}
+
+	out, err := runCmd(t, "login", "--env", "dev")
+	if err != nil {
+		t.Fatalf("login after an expired session: %v", err)
+	}
+	if presented {
+		t.Error("must not present a locally-expired token to the backend")
+	}
+	if *codes != 1 {
+		t.Errorf("requested %d device codes, want 1 after an expired session", *codes)
+	}
+	if !strings.Contains(out, "expired at "+past) {
+		t.Errorf("expected the expiry to be named, got:\n%s", out)
+	}
+}
+
+// TestLogin_UnexpiredSessionIsStillProbed guards the other side of the expiry
+// arm: an expires_at in the FUTURE must not be taken as proof on its own — a
+// revoked token still has an unexpired timestamp on disk.
+func TestLogin_UnexpiredSessionIsStillProbed(t *testing.T) {
+	probed := false
+	codes := loginBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		probed = true
+		okUserinfo(w, r)
+	})
+	future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
+	if err := (&config.Config{CurrentEnv: "dev", Profiles: map[string]*config.Profile{
+		"dev": {Token: "live_tok", Email: "ds@co", ExpiresAt: future},
+	}}).Save(); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := runCmd(t, "login", "--env", "dev"); err != nil {
+		t.Fatalf("login: %v", err)
+	}
+	if !probed {
+		t.Error("an unexpired session must still be confirmed with the backend")
+	}
+	if *codes != 0 {
+		t.Errorf("requested %d device codes; the confirmed session must short-circuit", *codes)
+	}
+}
+
+// TestLogin_UpgradeRequiredDoesNotStartAFlow: a 426 says this CLI is below the
+// server's version floor — a device flow would hit the same floor, so surface the
+// upgrade instruction instead of demanding a browser approval that cannot help.
+func TestLogin_UpgradeRequiredDoesNotStartAFlow(t *testing.T) {
+	codes := loginBackend(t, func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(http.StatusUpgradeRequired) // 426
+		_, _ = w.Write([]byte(`{"error":"upgrade_required","min_version":"1.2.3"}`))
+	})
+	saveSignedIn(t, "live_tok")
+
+	_, err := runCmd(t, "login", "--env", "dev")
+	if err == nil || !strings.Contains(err.Error(), "too old") {
+		t.Fatalf("a 426 must surface the upgrade message, got: %v", err)
+	}
+	if *codes != 0 {
+		t.Errorf("requested %d device codes; a 426 must not start a flow", *codes)
+	}
+}
+
+// TestLogin_ReuseAdoptsTheTargetEnvsOwnProfile: profiles are per-env (R10), so a
+// machine signed in to prod can hold a live dev token. `login --env dev` must use
+// it AND switch current_env — answering "already signed in" without moving the
+// pointer would leave every following command talking to prod.
+func TestLogin_ReuseAdoptsTheTargetEnvsOwnProfile(t *testing.T) {
+	codes := loginBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		if r.Header.Get("Authorization") != "Bearer dev_tok" {
+			t.Errorf("probed with %q, want the dev profile's token", r.Header.Get("Authorization"))
+		}
+		okUserinfo(w, r)
+	})
+	if err := (&config.Config{CurrentEnv: "prod", Profiles: map[string]*config.Profile{
+		"prod": {Token: "prod_tok", Email: "ds@co"},
+		"dev":  {Token: "dev_tok", Email: "ds@co"},
+	}}).Save(); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := runCmd(t, "login", "--env", "dev"); err != nil {
+		t.Fatalf("login --env dev over a stored dev session: %v", err)
+	}
+	if *codes != 0 {
+		t.Errorf("requested %d device codes; the stored dev session must be reused", *codes)
+	}
+	cfg, _ := config.Load()
+	if cfg.CurrentEnv != "dev" {
+		t.Errorf("current_env = %q, want dev (the short-circuit must still switch env)", cfg.CurrentEnv)
+	}
+	if got := cfg.Profiles["prod"].Token; got != "prod_tok" {
+		t.Errorf("prod token = %q, want prod_tok left intact (R10)", got)
+	}
+}
+
+// TestLogin_ReuseKeepsTheRawProfileKey: Profiles is keyed on the RAW env string,
+// and a v1-migrated config stores it verbatim ("Dev"). Reusing that session must
+// write back under the key it was FOUND under — deriving a fresh, lower-cased key
+// would mint a second profile beside the real one and strand the token.
+func TestLogin_ReuseKeepsTheRawProfileKey(t *testing.T) {
+	codes := loginBackend(t, okUserinfo)
+	if err := (&config.Config{CurrentEnv: "Dev", Profiles: map[string]*config.Profile{
+		"Dev": {Token: "live_tok", Email: "ds@co"},
+	}}).Save(); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := runCmd(t, "login", "--env", "dev"); err != nil {
+		t.Fatalf("login: %v", err)
+	}
+	if *codes != 0 {
+		t.Errorf("requested %d device codes; a `Dev`-keyed session still resolves to dev", *codes)
+	}
+	cfg, _ := config.Load()
+	if len(cfg.Profiles) != 1 {
+		t.Errorf("profiles = %v, want the single existing one (no duplicate key)", cfg.Profiles)
+	}
+	if p := cfg.Profiles["Dev"]; p == nil || p.Token != "live_tok" {
+		t.Errorf("the `Dev` profile lost its token: %+v", cfg.Profiles)
+	}
+}
+
+// TestLogin_NoStoredSessionStillSignsIn: the short-circuit must be invisible on
+// the path it doesn't apply to — a machine with no session gets the device flow
+// exactly as before, with no extra probe.
+func TestLogin_NoStoredSessionStillSignsIn(t *testing.T) {
+	probes := 0
+	codes := loginBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		probes++
+		okUserinfo(w, r)
+	})
+
+	if _, err := runCmd(t, "login", "--env", "dev"); err != nil {
+		t.Fatalf("login on a fresh machine: %v", err)
+	}
+	if *codes != 1 {
+		t.Errorf("requested %d device codes, want 1", *codes)
+	}
+	// One probe only: login's own post-flow confirmation. A signed-out machine
+	// has nothing to check beforehand.
+	if probes != 1 {
+		t.Errorf("%d /userinfo/ calls, want 1 (the post-flow confirmation only)", probes)
+	}
+}
+
+// TestLogin_ReuseFindsARawKeyedProfileThatIsNotCurrent (Bugbot, PR #658) is the
+// gap TestLogin_ReuseKeepsTheRawProfileKey could not see. That test keeps the
+// `"Dev"` profile CURRENT, so arm 1 of storedSessionFor catches it and the map
+// lookup is never exercised. Once a `login --env` elsewhere moves current_env,
+// only arm 2 is left — and indexing the map with the already-normalised target
+// misses `"Dev"` entirely, starting a flow and saving a second `"dev"` profile
+// beside a perfectly good session.
+func TestLogin_ReuseFindsARawKeyedProfileThatIsNotCurrent(t *testing.T) {
+	codes := loginBackend(t, func(w http.ResponseWriter, r *http.Request) {
+		if got := r.Header.Get("Authorization"); got != "Bearer dev_tok" {
+			t.Errorf("probed with %q, want the `Dev` profile's token", got)
+		}
+		okUserinfo(w, r)
+	})
+	// Signed in to prod; the dev session exists under a v1-migrated raw key.
+	if err := (&config.Config{CurrentEnv: "prod", Profiles: map[string]*config.Profile{
+		"prod": {Token: "prod_tok", Email: "ds@co"},
+		"Dev":  {Token: "dev_tok", Email: "ds@co"},
+	}}).Save(); err != nil {
+		t.Fatal(err)
+	}
+
+	if _, err := runCmd(t, "login", "--env", "dev"); err != nil {
+		t.Fatalf("login --env dev over a `Dev`-keyed session: %v", err)
+	}
+	if *codes != 0 {
+		t.Errorf("requested %d device codes; the `Dev` session must be found and reused", *codes)
+	}
+	cfg, _ := config.Load()
+	if cfg.CurrentEnv != "Dev" {
+		t.Errorf("current_env = %q, want the key the profile was FOUND under", cfg.CurrentEnv)
+	}
+	if _, dup := cfg.Profiles["dev"]; dup {
+		t.Errorf("a duplicate lower-cased profile was minted: %v", cfg.Profiles)
+	}
+	if p := cfg.Profiles["Dev"]; p == nil || p.Token != "dev_tok" {
+		t.Errorf("the `Dev` profile lost its token: %+v", cfg.Profiles)
+	}
+}
+
+// TestProfileKeyed_ExactMatchWinsAndFoldIsDeterministic: with both `"dev"` and
+// `"Dev"` on disk the answer must not ride on Go's randomised map iteration.
+// Exact match wins; the fold is only a tie-break, scanned in sorted order.
+func TestProfileKeyed_ExactMatchWinsAndFoldIsDeterministic(t *testing.T) {
+	cfg := &config.Config{Profiles: map[string]*config.Profile{
+		"Dev": {Token: "raw_tok"},
+		"dev": {Token: "exact_tok"},
+		"DEV": {Token: "shouty_tok"},
+	}}
+	for i := 0; i < 50; i++ {
+		key, prof := profileKeyed(cfg, "dev")
+		if key != "dev" || prof.Token != "exact_tok" {
+			t.Fatalf("iteration %d: got (%q, %q), want the exact `dev` match", i, key, prof.Token)
+		}
+	}
+	// With no exact key, the sorted fold must still answer the same way every time.
+	delete(cfg.Profiles, "dev")
+	for i := 0; i < 50; i++ {
+		key, _ := profileKeyed(cfg, "dev")
+		if key != "DEV" { // "DEV" sorts before "Dev"
+			t.Fatalf("iteration %d: fold returned %q, want a stable sorted pick", i, key)
+		}
+	}
+	// A profile with no token is not a session.
+	cfg.Profiles = map[string]*config.Profile{"Dev": {Email: "ds@co"}}
+	if key, prof := profileKeyed(cfg, "dev"); prof != nil {
+		t.Errorf("a tokenless profile matched: (%q, %+v)", key, prof)
+	}
+}
+
+// TestLogin_CancelDuringTheProbeExits130 (Bugbot, PR #658): Ctrl-C landing on the
+// new session probe is the operator, not an unverifiable session. Falling through
+// printed "signing in again" and then failed RequestDeviceCode with exit 1, where
+// every other interrupt in login exits 130 silently.
+func TestLogin_CancelDuringTheProbeExits130(t *testing.T) {
+	t.Setenv("TRACEBLOC_CONFIG_DIR", t.TempDir())
+	codes := 0
+	ctx, cancel := context.WithCancel(context.Background())
+	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		switch r.URL.Path {
+		case "/userinfo/":
+			cancel() // the operator hits Ctrl-C mid-probe
+			<-r.Context().Done()
+		case "/device/code":
+			codes++
+			_, _ = w.Write([]byte(`{"device_code":"dc","user_code":"X","verification_uri":"https://x/a","expires_in":600,"interval":5}`))
+		}
+	}))
+	t.Cleanup(srv.Close)
+	orig := newAPIClient
+	newAPIClient = func(string) *api.Client { return &api.Client{BaseURL: srv.URL, HTTP: srv.Client()} }
+	t.Cleanup(func() { newAPIClient = orig })
+
+	saveSignedIn(t, "live_tok") // CurrentEnv=dev
+	err := runLogin(ctx, ui.New(io.Discard, ui.WithColor(false)), "dev", false)
+
+	if got := ExitCodeFromError(err); got != exitInterrupted {
+		t.Fatalf("exit code = %d, want %d (a cancelled probe is an interrupt)", got, exitInterrupted)
+	}
+	if !IsSilentError(err) {
+		t.Errorf("an interrupt must exit quietly, got: %v", err)
+	}
+	if codes != 0 {
+		t.Errorf("requested %d device codes; Ctrl-C must not start a flow", codes)
+	}
+}
+
+// TestClassifyWhoAmIError pins the three-way distinction both session probes
+// share (review on PR #658). The arms are not interchangeable: only
+// whoAmIRejected is a statement about the credential, so a 5xx landing in it
+// would send someone to re-authenticate during an outage, and a 426 landing
+// there would send them to a browser step that cannot lift a version floor.
+func TestClassifyWhoAmIError(t *testing.T) {
+	for _, tc := range []struct {
+		name    string
+		err     error
+		want    whoAmIVerdict
+		wantMin string // the server's floor, when the verdict carries one
+	}{
+		{"401 is a rejection", &api.APIError{StatusCode: http.StatusUnauthorized}, whoAmIRejected, ""},
+		{"403 is a rejection", &api.APIError{StatusCode: http.StatusForbidden}, whoAmIRejected, ""},
+		{"426 is a version floor", &api.UpgradeRequiredError{MinVersion: "1.2.3"}, whoAmIUpgradeRequired, "1.2.3"},
+		{"500 is not a verdict", &api.APIError{StatusCode: http.StatusInternalServerError}, whoAmIUnverified, ""},
+		{"404 is not a rejection", &api.APIError{StatusCode: http.StatusNotFound}, whoAmIUnverified, ""},
+		{"429 is not a rejection", &api.APIError{StatusCode: http.StatusTooManyRequests}, whoAmIUnverified, ""},
+		{"a transport error is not a verdict", errors.New("dial tcp: no such host"), whoAmIUnverified, ""},
+		{"a cancelled context is not a rejection", context.Canceled, whoAmIUnverified, ""},
+		// Wrapped, because both call sites get their error back through the api
+		// client's own fmt.Errorf wrapping — matching on the concrete type only
+		// would silently demote every real verdict to "unverified".
+		{"wrapped 401 still a rejection", fmt.Errorf("confirming: %w",
+			&api.APIError{StatusCode: http.StatusUnauthorized}), whoAmIRejected, ""},
+		{"wrapped 426 still a version floor", fmt.Errorf("confirming: %w",
+			&api.UpgradeRequiredError{MinVersion: "9.9.9"}), whoAmIUpgradeRequired, "9.9.9"},
+	} {
+		t.Run(tc.name, func(t *testing.T) {
+			got, ue := classifyWhoAmIError(tc.err)
+			if got != tc.want {
+				t.Errorf("verdict = %d, want %d", got, tc.want)
+			}
+			switch {
+			case tc.wantMin != "":
+				if ue == nil || ue.MinVersion != tc.wantMin {
+					t.Errorf("upgrade error = %+v, want MinVersion %q", ue, tc.wantMin)
+				}
+			case ue != nil:
+				t.Errorf("upgrade error = %+v, want nil", ue)
+			}
+		})
+	}
+}
diff --git a/internal/cli/client.go b/internal/cli/client.go
index d69dbf5..63cb335 100644
--- a/internal/cli/client.go
+++ b/internal/cli/client.go
@@ -164,12 +164,21 @@ func clientPrompter() prompter {
 // and the file is hand-written in fixtures) failed `auth status --check --env dev`
 // against a session that works perfectly.
 func sessionEnv(cfg *config.Config) string {
-	if e := strings.ToLower(strings.TrimSpace(cfg.CurrentEnv)); e != "" {
+	if e := normalizeEnv(cfg.CurrentEnv); e != "" {
 		return e
 	}
 	return api.ResolveEnv("")
 }
 
+// normalizeEnv is the trim+lower-case sessionEnv applies, as a pure function, so
+// the one other place that has to COMPARE a raw stored env string — the profile
+// lookup in auth.go, which reads the Profiles map's own keys — folds it exactly
+// the same way. Two hand-rolled copies of this is how a `"Dev"` key stops
+// matching a `dev` target.
+func normalizeEnv(env string) string {
+	return strings.ToLower(strings.TrimSpace(env))
+}
+
 // knownSessionEnv resolves the session env like sessionEnv and then REJECTS an
 // unrecognised value, instead of letting api.BaseURL fall it back to prod. Every
 // path that attaches the stored TOKEN — authedClient, logout's server-side revoke,
diff --git a/internal/cli/testdata/golden/07-login.golden b/internal/cli/testdata/golden/07-login.golden
index db8da7e..1b152ec 100644
--- a/internal/cli/testdata/golden/07-login.golden
+++ b/internal/cli/testdata/golden/07-login.golden
@@ -15,6 +15,12 @@ on any device (your laptop or phone), sign in the way you already do
 (password, Google, or GitHub), and approve the code. The CLI stores a
 user token in ~/.tracebloc (mode 0600).
 
+Already signed in to this backend? login says so and exits 0 without a
+browser step — so re-running it is safe in a script or a runbook, and a
+headless box is never asked to approve a code it already has. Pass
+--force to re-authenticate anyway: switching accounts, or replacing a
+session you believe is stale.
+
 Works on a headless / SSH box — the browser and the CLI need not share a
 machine. Honors HTTP(S)_PROXY / NO_PROXY for corporate-proxy networks.
 
@@ -23,6 +29,7 @@ Usage:
 
 Flags:
       --env string   backend environment: dev|stg|prod (default: $TRACEBLOC_ENV, then legacy $CLIENT_ENV, then prod)
+      --force        start a new device flow even when this machine already holds a valid session
   -h, --help         help for login
 
 Global Flags:
diff --git a/internal/cli/testdata/golden/zz-all-strings.golden b/internal/cli/testdata/golden/zz-all-strings.golden
index e4ccd85..1559a4c 100644
--- a/internal/cli/testdata/golden/zz-all-strings.golden
+++ b/internal/cli/testdata/golden/zz-all-strings.golden
@@ -136,6 +136,8 @@ screen. %s/%d are runtime placeholders.
 "A training run is allocated up to:"
 "Add --help to any command for the flags."
 "Add/resize a node to meet the job's requests, or lower RESOURCE_REQUESTS on jobs-manager."
+"Already signed in as %s."
+"Already signed in."
 "Already signed out."
 "Applies to your next training run; a run already going keeps its size."
 "Applying the resource change…"
@@ -173,6 +175,7 @@ screen. %s/%d are runtime placeholders.
 "Copying %s"
 "Correlation id: %s"
 "Couldn't check for active training runs (%v) — continuing; the confirmation below still guards you."
+"Couldn't check the session saved on this machine (%v) — signing in again."
 "Couldn't clear the stored active-client pointer (%v) — the on-disk config still names the revoked client; run `tracebloc logout` or remove it by hand."
 "Couldn't connect to your secure environment — check your kubeconfig/context."
 "Couldn't determine this client's namespace — skipped the Helm uninstall. If a release is still installed, re-run with --namespace ."
@@ -352,6 +355,7 @@ screen. %s/%d are runtime placeholders.
 "Review"
 "Revoked this machine's credential — your secure environment %q stays on tracebloc as a record."
 "Run '%s --help' for the available commands."
+"Run `tracebloc login --force` to sign in again — switching accounts, or replacing a session you believe is stale."
 "SELECT '%s',%s,COUNT(*),%s,%s FROM `%s`.`%s`"
 "SELECT r.table_name, COALESCE(r.task,'') FROM `%s`.`%s` r JOIN (SELECT table_name, MAX(started_at) ms FROM `%s`.`%s` WHERE task IS NOT NULL GROUP BY table_name) m ON r.table_name = m.table_name AND r.started_at = m.ms WHERE r.task IS NOT NULL"
 "SELECT table_name FROM information_schema.tables WHERE table_schema='%s' ORDER BY table_name"
@@ -390,11 +394,13 @@ screen. %s/%d are runtime placeholders.
 "Target verified with tracebloc: %s (namespace %s) — cluster %s."
 "Task:"
 "Text      a folder with labels.csv + texts/     e.g. %s"
+"The backend rejected the session saved on this machine — it expired or was revoked. Signing in again."
 "The column holding the duration / time-to-event.  e.g. time, tenure_days"
 "The dataset's catalog metadata is kept as a record on tracebloc, marked unavailable — never removed."
 "The ingestion hasn't started yet (usually a slow image pull or a busy cluster). It's queued to run once the cluster can schedule it — check on it with the command below."
 "The name you provided was only control characters — auto-naming this client instead."
 "The number of landmark points each sample is annotated with — dataset-specific.  e.g. 17 for COCO human pose"
+"The session saved on this machine expired at %s — signing in again."
 "The size your images already are, as WxH — tracebloc checks every image matches and never resizes. Press Enter to read it from your first image.  e.g. 224x224"
 "The tracebloc CLI (your local data & config are kept — --keep-data)"
 "This CLI is out of date — update it: %s"
@@ -475,6 +481,7 @@ screen. %s/%d are runtime placeholders.
 "authorized — confirming the token with the backend …"
 "auto-detect"
 "backend"
+"backend %s — checking the session already on this machine …"
 "backend %s — requesting a device code …"
 "backfilling the cluster anchor onto the existing client: %w"
 "bucket bins the target before it leaves the cluster"

From ad3d91975eeacceffdb017ebc5ec4278cd718d2c Mon Sep 17 00:00:00 2001
From: lukasWuttke <54042461+LukasWodka@users.noreply.github.com>
Date: Fri, 11 Sep 2026 13:56:54 +0200
Subject: [PATCH 4/4] feat(scripts): one-shot release backfill to the public
 mirror (all tags, newest 10 binaries) (#659)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat(scripts): one-shot release backfill to the public mirror (all tags, newest 10 binaries)

scripts/backfill-releases.sh carries this repository's historical releases
onto the public deliverable mirror once; mirror-publish.yml covers every
release cut after the mirror exists.

Decision: every published release gets its tag, its GitHub release and its
text assets (install.sh, install.ps1, SHA256SUMS, anything SHA256SUMS does
not list); binaries and their .sig/.cert only for the newest BINARY_KEEP
(default 10) releases. Mirror tags are annotated release markers on the
mirror's default-branch head carrying the original date and message, since
the mirror has no source commit to point at.

Reuses publish-mirror.sh `target` for the mirror-name rule and
publish-guard.sh for the string scan of every text asset and release body;
binaries are verified against the source SHA256SUMS before upload. Fails
closed (exit 2 on any incomplete read), refuses per release (exit 1),
idempotent (a re-run over a complete mirror writes nothing), resumable
(--from-tag / --only-tag). Dry-run is the default.

scripts/tests/backfill-releases-verify.sh: 30 offline assertions against a
recording fake gh, plus --mutations proving 6 anchored rules load-bearing.
Both wired into the Installer (shell) job. RELEASE_CHECKLIST.md gains §8.

Co-Authored-By: Claude Fable 5.1 

* feat(scripts): backfill defaults to the workflow's fixed release notes; --notes source is the opt-in

Historical release bodies are GitHub's generated pull-request lists, and
nearly every one carries strings the publish guard's report tier counts.
The public mirror should not repeat them, so the backfill now writes the
same fixed notes mirror-publish.yml writes for new releases by default,
plus the original-date footer. `--notes source` still carries the source
body on explicit request and runs it through the guard as before.

Tests: the default path now expects fixed notes and no trace of the
source body; `--notes source` has its own positive test; the bad-body
refusal and the --strict report-tier refusal run under `--notes source`,
with a paired default-notes run proving the body is never staged. The
mutation list gains `notes-default-fixed` (flips the default back), which
reddens four tests.

Co-Authored-By: Claude Fable 5.1 

* fix(scripts): die2 reports on stderr so a could-not-tell inside $(...) is seen, not swallowed

Nearly every jq_of call sits inside a "$(...)" assignment. die2 wrote its
::error:: reason to stdout, so inside the substitution the reason was captured
into the variable and never printed; the subshell's exit 2 ended only the
subshell, and the parent died under set -e with a bare status 2 the operator
could not name. The prerelease read sat inside a "[ ... ]" test in an && list,
where set -e is suspended, so that failure did not end the run at all - a
malformed release.json read as "stable".

- die2 writes to stderr: the reason reaches the operator from any depth, and
  the substitution's status 2 still aborts the assignment under set -e.
- the prerelease jq_of is hoisted into its own assignment before the test.
- harness: the fake gh gains FAKE_GH_GARBLE_RE (a call that "succeeds" with a
  non-JSON body); a new case pins that a garbled read parsed inside a
  substitution is exit 2 naming the file and filter with the reason in the
  output; a die2-stderr mutation (stdout die2) must redden it - proven: the
  mutant reddens exactly that case, 8 caught / 0 survived.

Co-Authored-By: Claude Fable 5.1 

* fix(scripts): backfill marks a surviving stable release latest when the newest is refused; scratch commit is never signed

Two review findings, both confirmed against the script:

1. make_latest=true travelled only on the newest stable release's own POST,
   every older release being created with make_latest=false. When the newest
   stable was refused before that POST (SHA mismatch, guard hit, dangling tag)
   nothing on the mirror was marked latest and releases/latest answered 404
   until a human noticed the exit 1 and re-ran --only-tag. After the loop, an
   --apply run whose newest stable was refused AT CREATE TIME now PATCHes the
   newest stable release it did write to make_latest=true and says so; a
   refusal of a release the mirror already has leaves latest untouched, and
   a run that wrote no stable release warns and names the re-run. The
   happy path is unchanged (still 48 writes for 12 releases).

2. The guard's scratch checkout committed with only user.name/user.email
   overrides; a global commit.gpgsign=true on the operator's machine would try
   to sign as backfill@localhost, fail, and end the run before a release was
   planned. The scratch commit is never published: -c commit.gpgsign=false.

Harness: the fake gh now returns real release ids and serves PATCH
repos//releases/; writes() counts PATCH; the sha-mismatch case
(which refuses v0.1.11, the newest stable) pins exactly one PATCH marking
v0.1.10 latest; a new case runs under GIT_CONFIG_GLOBAL with gpgsign=true
and a failing signer and demands a clean plan. Two mutations
(latest-fallback, scratch-commit-unsigned) each redden exactly their case:
37 passed, 10 caught / 0 survived.

Co-Authored-By: Claude Fable 5.1 

* fix(scripts): the latest fallback sends make_latest as the string the API takes, and the fake gh refuses a typed one

The fallback PATCH added in the previous commit used -F make_latest=true,
which gh types as a JSON boolean. The releases API takes make_latest as a
string enum ("true"/"false"/"legacy") - the create path two lines away uses
-f for exactly that reason - so the PATCH would 422, die2 would fire, and
the one scenario the fallback exists for ended with the fallback failing
closed and releases/latest still 404ing. Now -f, matching the create path.

The harness let this through because the fake gh treated -f and -F alike.
It now records which fields arrived typed and answers a typed make_latest on
either release call with the API's 422, so the type is enforced where the
real endpoint enforces it. A latest-string-typed mutation (the -F line)
reddens the sha-mismatch and fallback cases: 37 passed, 11 caught / 0
survived.

Co-Authored-By: Claude Fable 5.1 

---------

Co-authored-by: Claude Fable 5.1 
---
 .github/workflows/build.yml               |  16 +
 scripts/RELEASE_CHECKLIST.md              |  38 ++
 scripts/backfill-releases.sh              | 575 ++++++++++++++++++++++
 scripts/tests/backfill-releases-verify.sh | 570 +++++++++++++++++++++
 4 files changed, 1199 insertions(+)
 create mode 100644 scripts/backfill-releases.sh
 create mode 100644 scripts/tests/backfill-releases-verify.sh

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index ca61794..1bf1dd1 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -101,6 +101,10 @@ jobs:
           bash -n scripts/tests/mirror-publish-workflow-verify.sh
           bash -n scripts/publish-guard.sh
           bash -n scripts/publish-mirror.sh
+          shellcheck --shell=bash --severity=warning scripts/backfill-releases.sh
+          shellcheck --shell=bash --severity=warning scripts/tests/backfill-releases-verify.sh
+          bash -n scripts/backfill-releases.sh
+          bash -n scripts/tests/backfill-releases-verify.sh
       # format.sh's own fail-closed properties. Formatters are stubbed, so this is
       # hermetic and needs no Go toolchain — which is why it lives in this job
       # rather than Lint. It exists because the first cut of format.sh reported
@@ -152,6 +156,18 @@ jobs:
       # out of the YAML and executed with `gh` shimmed, so this is hermetic.
       - name: Mirror-publish workflow harness (step bodies / shape / mutations)
         run: bash scripts/tests/mirror-publish-workflow-verify.sh
+      # The one-shot historical backfill (scripts/backfill-releases.sh): dry-run
+      # writes nothing, --apply makes exactly the expected writes and a second
+      # --apply none, binaries stop at the BINARY_KEEP boundary, a binary that
+      # disagrees with SHA256SUMS or a forbidden string in a body refuses by
+      # name, a failed read is could-not-tell. `gh` is a recording fake serving
+      # fixtures, so this is hermetic. The second step breaks one rule per copy
+      # of the script and demands the same suite go red — a rule the suite
+      # cannot see reddens the build.
+      - name: Release-backfill harness (zero-write dry-run / idempotent / fail-closed)
+        run: bash scripts/tests/backfill-releases-verify.sh
+      - name: Release-backfill harness — mutations (every anchored rule is load-bearing)
+        run: bash scripts/tests/backfill-releases-verify.sh --mutations
 
   test:
     timeout-minutes: 15
diff --git a/scripts/RELEASE_CHECKLIST.md b/scripts/RELEASE_CHECKLIST.md
index 38d6ec2..fdec18b 100644
--- a/scripts/RELEASE_CHECKLIST.md
+++ b/scripts/RELEASE_CHECKLIST.md
@@ -48,6 +48,44 @@ have to reverse-engineer the surface area on release day.
    pinned to the mirror's current default-branch head — the mirror's
    default branch keeps the last stable release.
 
+8. Releases that predate the mirror are carried over ONCE, by hand, with
+   `scripts/backfill-releases.sh` (the workflow only publishes releases
+   cut after it exists). The decision it implements: every published
+   release gets its tag, its GitHub release and its text assets
+   (`install.sh`, `install.ps1`, `SHA256SUMS`, anything else SHA256SUMS
+   does not list); the binaries and their `.sig`/`.cert` only for the
+   newest `BINARY_KEEP` releases (default 10) — older pinned binary
+   URLs 404 on the mirror, and the answer is "re-run the installer".
+   Prereleases are skipped unless `--include-prerelease`. Mirror tags
+   are annotated RELEASE MARKERS on the mirror's default-branch head,
+   carrying the original date and message — the mirror has no source
+   commit to point at, and the annotation says so. Release notes are
+   the same fixed text the workflow writes (`--notes fixed`, the
+   default) plus a footer naming the original publish date — the
+   historical bodies are GitHub's generated pull-request lists, and
+   nearly every one carries strings the guard's report tier counts,
+   which the mirror should not repeat. `--notes source` carries the
+   source body instead, as an explicit opt-in. Every text asset and
+   every release body goes through `publish-guard.sh` first; every
+   binary is checked against the source's `SHA256SUMS`; anything
+   already on the mirror with the same SHA256 is skipped, so a re-run
+   writes nothing. Dry-run is the default:
+
+   ```bash
+   MIRROR_REPO= scripts/backfill-releases.sh            # plan
+   MIRROR_REPO= scripts/backfill-releases.sh --apply    # write
+   # resume after a failure, or redo one release:
+   MIRROR_REPO= scripts/backfill-releases.sh --apply --from-tag vX.Y.Z
+   MIRROR_REPO= scripts/backfill-releases.sh --apply --only-tag vX.Y.Z
+   ```
+
+   Needs `gh` (token with write on the mirror), `jq`, `gitleaks`; set
+   `BACKFILL_EXTRA_FORBIDDEN` to a file with the private needle list
+   the workflow gets from its secret, or the string scan runs without
+   them. Exit 1 means at least one release was refused (the table says
+   which and why); exit 2 means a read did not complete and nothing was
+   written. The script's header carries the full contract.
+
 GitHub Releases plus the cosign-verified `install.sh` are the
 install path — a Homebrew tap and the `install.tracebloc.io`
 vanity URL were considered and dropped
diff --git a/scripts/backfill-releases.sh b/scripts/backfill-releases.sh
new file mode 100644
index 0000000..46026ff
--- /dev/null
+++ b/scripts/backfill-releases.sh
@@ -0,0 +1,575 @@
+#!/usr/bin/env bash
+# =============================================================================
+#  backfill-releases.sh — one-shot backfill of this repository's HISTORICAL
+#  releases onto the public deliverable mirror.
+#
+#  .github/workflows/mirror-publish.yml publishes each NEW release to the
+#  mirror as it is cut. Releases that existed before the mirror did are carried
+#  over once, by this script, run by a human. Idempotent: a re-run over an
+#  already-backfilled mirror reads everything and writes nothing.
+#
+#  THE DECISION (taken once, stated here so nobody re-derives it):
+#    * every published release gets its tag and its GitHub release on the
+#      mirror, with its TEXT assets — install.sh, install.ps1, SHA256SUMS and
+#      any other asset SHA256SUMS does not list;
+#    * BINARIES (the files SHA256SUMS lists) and their cosign companions
+#      (.sig, .cert) are carried only for the newest
+#      BINARY_KEEP releases (default 10). Older pinned binary URLs 404 on the
+#      mirror; the documented answer is "re-run the installer";
+#    * prereleases and drafts are skipped unless --include-prerelease.
+#
+#  HOW MIRROR TAGS ARE ANCHORED: the mirror holds a README, not the source, so
+#  no tag can point at the commit a release was built from. Each tag is created
+#  as an ANNOTATED tag on the mirror's default-branch head. The annotation
+#  carries the ORIGINAL date (the source tag's tagger date when the source tag
+#  is annotated, the release's created_at otherwise) and the original message,
+#  and says in plain words that the tag is a RELEASE MARKER on the mirror, not
+#  a source snapshot. A tag already on the mirror is accepted only if it points
+#  at a commit the mirror has; a dangling one is refused, never repointed.
+#
+#  RELEASE NOTES: --notes fixed (DEFAULT) writes the same fixed text the
+#  workflow writes for new releases. Historical source bodies are GitHub's
+#  generated ones — merged pull requests by title — and nearly every one
+#  carries strings the guard's report tier counts, which the public mirror
+#  should not repeat; so the source body is an explicit opt-in: --notes source
+#  carries it, and it then goes through the guard's forbidden-string scan like
+#  any text asset (a hit refuses the release and names the tier). Either way a
+#  footer names the original publish date (GitHub does not let a created
+#  release carry a past date, so the footer and the tag annotation are where
+#  the date survives).
+#
+#  WHAT IS REUSED: scripts/publish-mirror.sh `target` decides the mirror name
+#  (unset, malformed, or equal to the source is refused there — one rule, one
+#  place); scripts/publish-guard.sh scans every text asset and the notes with
+#  the repo's own .publish-forbidden (and the private needles from
+#  BACKFILL_EXTRA_FORBIDDEN, when given) plus gitleaks, before anything is
+#  uploaded. Binaries are opaque to a string scan by design; each one is
+#  verified against the SOURCE release's SHA256SUMS before upload and refused
+#  on a mismatch, naming the asset. Companions (.sig/.cert) are downloaded
+#  with their binary under --apply and scanned like text assets then.
+#
+#  Usage:
+#    MIRROR_REPO=NAME scripts/backfill-releases.sh [--dry-run | --apply]
+#        [--from-tag TAG | --only-tag TAG] [--include-prerelease]
+#        [--notes fixed|source] [--strict]
+#
+#  Environment:
+#    SOURCE_REPO     OWNER/REPO to read releases from (default: the repository
+#                    `gh repo view` reports for the current checkout)
+#    MIRROR_REPO     bare repository name in the source's organisation; REQUIRED
+#    BINARY_KEEP     how many of the newest releases carry binaries (default 10)
+#    BACKFILL_EXTRA_FORBIDDEN
+#                    file of extra refuse-tier needles for the guard (the
+#                    private list the workflow gets from a secret); optional
+#    PUBLISH_MIRROR_GIT_NAME / PUBLISH_MIRROR_GIT_EMAIL
+#                    tagger identity on the mirror tags (default github-actions[bot])
+#    GH_TOKEN        gh's; must be able to write the mirror under --apply
+#
+#  Modes:
+#    --dry-run       DEFAULT. Every read runs, the text assets and notes of
+#                    each release that would change are downloaded and put
+#                    through the guard, the plan is printed, nothing is written.
+#    --apply         performs the writes: tag, release, uploads.
+#    --from-tag TAG  resume: process TAG and every release newer than it
+#                    (releases are processed oldest to newest).
+#    --only-tag TAG  process TAG alone.
+#                    Both keep the BINARY_KEEP decision of the FULL list, so a
+#                    partial run carries the same binaries a full run would.
+#
+#  Exit 0 done (every release created or already present); 1 at least one
+#  release was REFUSED (the table says which and why; the rest went ahead);
+#  2 COULD NOT TELL — a read that did not complete, a tool missing, an input
+#  malformed. "Cannot tell" stops the run at once and never writes.
+# =============================================================================
+set -euo pipefail
+
+SCRIPTS_DIR="${BACKFILL_SCRIPTS_DIR:-$(cd "$(dirname "$0")" && pwd)}"
+REPO_ROOT="$(cd "$SCRIPTS_DIR/.." && pwd)"
+PUBLISH_MIRROR="$SCRIPTS_DIR/publish-mirror.sh"
+PUBLISH_GUARD="$SCRIPTS_DIR/publish-guard.sh"
+FORBIDDEN_LIST="$REPO_ROOT/.publish-forbidden"
+
+# die2 REASON — could-not-tell: the reason, then exit 2. The reason goes to
+# STDERR on purpose: most callers (jq_of above all) sit inside "$(...)", where
+# stdout is the variable being assigned — a stdout reason would be captured
+# into it and never seen, leaving a bare exit 2. On stderr it reaches the
+# operator either way, and the substitution's status 2 aborts the assignment
+# under `set -e`. That abort is the ONLY thing ending the parent, so never
+# put a die2-capable "$(...)" inside an && / || list or a `[ ]` test, where
+# `set -e` is suspended — hoist it into its own assignment first (the
+# per-release block does).
+die2() { echo "::error::backfill-releases: COULD NOT TELL — $1 (nothing more is written)" >&2; exit 2; }   # mutation-anchor: die2-stderr
+note() { echo "backfill-releases: $1"; }
+
+# ---- arguments -----------------------------------------------------------------
+APPLY=0; FROM_TAG=""; ONLY_TAG=""; INCLUDE_PRE=0; NOTES_MODE=fixed; STRICT=0   # mutation-anchor: notes-default-fixed
+while [ "$#" -gt 0 ]; do
+  case "$1" in
+    --dry-run)            APPLY=0; shift ;;
+    --apply)              APPLY=1; shift ;;
+    --from-tag)           FROM_TAG="${2:-}"; shift 2 ;;
+    --only-tag)           ONLY_TAG="${2:-}"; shift 2 ;;
+    --include-prerelease) INCLUDE_PRE=1; shift ;;
+    --notes)              NOTES_MODE="${2:-}"; shift 2 ;;
+    --strict)             STRICT=1; shift ;;
+    -h|--help)            sed -n '2,/^# ====/p' "$0" | sed 's/^# \{0,2\}//'; exit 0 ;;
+    *) die2 "unknown argument '$1' (see --help)" ;;
+  esac
+done
+[ -z "$FROM_TAG" ] || [ -z "$ONLY_TAG" ] || die2 "--from-tag and --only-tag exclude each other"
+case "$NOTES_MODE" in fixed|source) ;; *) die2 "--notes must be 'fixed' or 'source', not '$NOTES_MODE'" ;; esac
+BINARY_KEEP="${BINARY_KEEP:-10}"
+[[ "$BINARY_KEEP" =~ ^[0-9]+$ ]] || die2 "BINARY_KEEP '$BINARY_KEEP' is not a non-negative integer"
+TAG_RE='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$'
+COMPANION_RE='\.(sig|cert)$'   # .sig / .cert travel with their binary
+[ -z "$FROM_TAG" ] || [[ "$FROM_TAG" =~ $TAG_RE ]] || die2 "--from-tag '$FROM_TAG' is not a release tag"
+[ -z "$ONLY_TAG" ] || [[ "$ONLY_TAG" =~ $TAG_RE ]] || die2 "--only-tag '$ONLY_TAG' is not a release tag"
+
+# ---- tools -----------------------------------------------------------------------
+for t in gh jq git awk; do command -v "$t" >/dev/null 2>&1 || die2 "'$t' is not on PATH"; done
+command -v "${PUBLISH_GUARD_GITLEAKS:-gitleaks}" >/dev/null 2>&1 || die2 "'${PUBLISH_GUARD_GITLEAKS:-gitleaks}' is not on PATH — the guard treats a missing scanner as could-not-tell, so nothing could be uploaded"
+[ -f "$PUBLISH_MIRROR" ] || die2 "$PUBLISH_MIRROR is missing"
+[ -f "$PUBLISH_GUARD" ]  || die2 "$PUBLISH_GUARD is missing"
+[ -r "$FORBIDDEN_LIST" ] || die2 "$FORBIDDEN_LIST is missing or unreadable — the scan has no rules"
+if [ -n "${BACKFILL_EXTRA_FORBIDDEN:-}" ]; then
+  [ -s "$BACKFILL_EXTRA_FORBIDDEN" ] || die2 "BACKFILL_EXTRA_FORBIDDEN '$BACKFILL_EXTRA_FORBIDDEN' is missing or empty"
+fi
+if command -v sha256sum >/dev/null 2>&1; then
+  sha256_of() { sha256sum "$1" | cut -d' ' -f1; }
+elif command -v shasum >/dev/null 2>&1; then
+  sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; }
+else
+  die2 "neither sha256sum nor shasum is on PATH"
+fi
+
+TMP="$(mktemp -d "${TMPDIR:-/tmp}/backfill-releases.XXXXXX")" && [ -d "$TMP" ] || die2 "could not create a scratch directory"
+trap 'rm -rf "$TMP"' EXIT
+
+# ---- gh wrappers -----------------------------------------------------------------
+# gh_read OUTFILE ARGS... — a READ that must complete. Any failure is
+# could-not-tell: an unreadable list is never an empty list.
+gh_read() {
+  local out="$1"; shift
+  if ! gh "$@" >"$out" 2>"$TMP/gh.err"; then
+    die2 "gh $* failed: $(tr '\n' ' ' <"$TMP/gh.err")"
+  fi
+}
+# gh_read_maybe OUTFILE ARGS... — a READ where "not there" is an answer.
+# Returns 0 on success, 1 on a clear HTTP 404 or the HTTP 409 GitHub gives for
+# a commit read on an EMPTY repository; anything else is could-not-tell.
+gh_read_maybe() {
+  local out="$1"; shift
+  if gh "$@" >"$out" 2>"$TMP/gh.err"; then return 0; fi
+  grep -qE 'HTTP 404|HTTP 409' "$TMP/gh.err" && return 1
+  die2 "gh $* failed: $(tr '\n' ' ' <"$TMP/gh.err")"
+}
+# gh_write OUTFILE ARGS... — a WRITE (apply only). A failed write is fatal:
+# the mirror may now be half-changed and the human decides, with the table.
+gh_write() {
+  local out="$1"; shift
+  [ "$APPLY" -eq 1 ] || die2 "internal: gh_write reached in dry-run (gh $*)"
+  if ! gh "$@" >"$out" 2>"$TMP/gh.err"; then
+    die2 "gh $* failed: $(tr '\n' ' ' <"$TMP/gh.err") — re-run to resume; completed steps are skipped"
+  fi
+}
+# jq_of FILE FILTER — jq over a file that MUST parse; a malformed answer is
+# could-not-tell, not an empty one.
+jq_of() { jq -r "$2" "$1" 2>"$TMP/jq.err" || die2 "could not parse $1 with '$2': $(tr '\n' ' ' <"$TMP/jq.err")"; }
+
+# ---- source and mirror ---------------------------------------------------------------
+SRC="${SOURCE_REPO:-}"
+if [ -z "$SRC" ]; then
+  gh_read "$TMP/self.json" repo view --json nameWithOwner
+  SRC="$(jq_of "$TMP/self.json" '.nameWithOwner')"
+fi
+[[ "$SRC" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]] || die2 "source repository '$SRC' is not OWNER/REPO"
+
+# The mirror name is decided by publish-mirror.sh `target`, so an unset or
+# malformed name and a mirror equal to the source are refused by the same rule
+# the workflow applies. Run directly, output captured to a file: its
+# ::error:: line is the reason, and its exit status is ours.
+rc=0
+bash "$PUBLISH_MIRROR" target --mirror "${MIRROR_REPO:-}" --source-repo "$SRC" >"$TMP/target.out" 2>&1 || rc=$?
+if [ "$rc" -ne 0 ]; then cat "$TMP/target.out"; echo "::error::backfill-releases: REFUSED — the mirror target was refused above"; exit "$rc"; fi
+MIRROR="$(tail -1 "$TMP/target.out")"
+
+gh_read "$TMP/mirror.json" api "repos/$MIRROR"
+MIRROR_FULL="$(jq_of "$TMP/mirror.json" '.full_name')"
+MIRROR_BRANCH="$(jq_of "$TMP/mirror.json" '.default_branch')"
+if [ "$(printf '%s' "$MIRROR_FULL" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$SRC" | tr '[:upper:]' '[:lower:]')" ]; then
+  echo "::error::backfill-releases: REFUSED — mirror '$MIRROR_FULL' resolves to the source repository"; exit 1
+fi
+[ -n "$MIRROR_BRANCH" ] && [ "$MIRROR_BRANCH" != null ] || die2 "mirror '$MIRROR' reports no default branch"
+# Every mirror tag is anchored here. An empty mirror has no head: that is a
+# refusal with instructions, not a guess.
+if ! gh_read_maybe "$TMP/head.json" api "repos/$MIRROR/commits/$MIRROR_BRANCH"; then
+  echo "::error::backfill-releases: REFUSED — mirror '$MIRROR' has no commit on '$MIRROR_BRANCH' to anchor tags to; publish the README first"; exit 1
+fi
+MIRROR_HEAD="$(jq_of "$TMP/head.json" '.sha')"
+[[ "$MIRROR_HEAD" =~ ^[0-9a-f]{40}$ ]] || die2 "mirror head '$MIRROR_HEAD' is not a commit sha"
+
+# ---- the release list, derived from the API -----------------------------------------
+# --paginate concatenates one JSON array per page; `jq -s add` joins them.
+gh_read "$TMP/src-pages.json" api --paginate "repos/$SRC/releases"   # mutation-anchor: releases-read-fail-closed
+jq -s 'add // []' "$TMP/src-pages.json" >"$TMP/src-releases.json" 2>"$TMP/jq.err" || die2 "release list of '$SRC' did not parse: $(tr '\n' ' ' <"$TMP/jq.err")"
+# Newest first by created_at; drafts never; prereleases only when asked.
+FILTER='[ .[] | select(.draft == false) | select($pre == 1 or .prerelease == false) ] | sort_by(.created_at) | reverse'   # mutation-anchor: prerelease-filter
+jq --argjson pre "$INCLUDE_PRE" "$FILTER" "$TMP/src-releases.json" >"$TMP/releases.json" 2>"$TMP/jq.err" || die2 "could not filter the release list: $(tr '\n' ' ' <"$TMP/jq.err")"
+N_ALL="$(jq_of "$TMP/releases.json" 'length')"
+[ "$N_ALL" -gt 0 ] || die2 "'$SRC' has no published release matching the filter — nothing to backfill is not a clean run"
+jq -r '.[].tag_name' "$TMP/releases.json" >"$TMP/tags-newest-first.txt"
+while IFS= read -r t; do [[ "$t" =~ $TAG_RE ]] || die2 "release tag '$t' on '$SRC' is not a release tag"; done <"$TMP/tags-newest-first.txt"
+# The newest BINARY_KEEP of the FULL filtered list carry binaries — decided
+# before --from-tag/--only-tag narrow the run, so a partial run agrees with a
+# full one.
+head -n "$BINARY_KEEP" "$TMP/tags-newest-first.txt" >"$TMP/tags-with-binaries.txt"   # mutation-anchor: binary-keep
+NEWEST_STABLE="$(jq_of "$TMP/releases.json" '[ .[] | select(.prerelease == false) ] | .[0].tag_name // ""')"
+carries_binaries() { grep -qxF -- "$1" "$TMP/tags-with-binaries.txt"; }
+
+# Processing order: oldest to newest, so the mirror's release order reads like
+# the source's and the newest stable release is created last.
+sed -n '1!G;h;$p' "$TMP/tags-newest-first.txt" >"$TMP/tags-ordered.txt"
+if [ -n "$ONLY_TAG" ]; then
+  grep -qxF -- "$ONLY_TAG" "$TMP/tags-ordered.txt" || die2 "--only-tag '$ONLY_TAG' is not a release of '$SRC' matching the filter"
+  printf '%s\n' "$ONLY_TAG" >"$TMP/tags-run.txt"
+elif [ -n "$FROM_TAG" ]; then
+  grep -qxF -- "$FROM_TAG" "$TMP/tags-ordered.txt" || die2 "--from-tag '$FROM_TAG' is not a release of '$SRC' matching the filter"
+  awk -v t="$FROM_TAG" 'f || $0 == t { f = 1; print }' "$TMP/tags-ordered.txt" >"$TMP/tags-run.txt"
+else
+  cp "$TMP/tags-ordered.txt" "$TMP/tags-run.txt"
+fi
+N_RUN="$(grep -c . "$TMP/tags-run.txt" || true)"
+
+# ---- what the mirror already has ----------------------------------------------------
+gh_read "$TMP/mirror-pages.json" api --paginate "repos/$MIRROR/releases"
+jq -s 'add // []' "$TMP/mirror-pages.json" >"$TMP/mirror-releases.json" 2>"$TMP/jq.err" || die2 "release list of '$MIRROR' did not parse"
+gh_read "$TMP/mirror-tag-pages.json" api --paginate "repos/$MIRROR/git/matching-refs/tags/"
+jq -s 'add // []' "$TMP/mirror-tag-pages.json" >"$TMP/mirror-tags.json" 2>"$TMP/jq.err" || die2 "tag list of '$MIRROR' did not parse"
+
+MODE=dry-run; [ "$APPLY" -eq 1 ] && MODE=apply
+note "source $SRC → mirror $MIRROR ($MIRROR_BRANCH @ ${MIRROR_HEAD:0:12}); $N_ALL release(s) match the filter, $N_RUN in this run; binaries for the newest $BINARY_KEEP; notes=$NOTES_MODE; mode=$MODE"
+[ "$STRICT" -eq 0 ] || note "--strict: the guard's [strings-report] tier refuses"
+
+# ---- the guard's scratch source tree -------------------------------------------------
+# publish-guard.sh stages a tree from a git checkout by design. The backfill has
+# no tree to publish, so it hands the guard a one-file scratch checkout and puts
+# what matters — the text assets and the notes — in --assets, where guards
+# 2–4 (forbidden paths, forbidden strings, gitleaks) read them.
+SCRATCH="$TMP/scratch-src"; mkdir -p "$SCRATCH"
+git -C "$SCRATCH" init -q || die2 "could not init the guard's scratch checkout"
+printf 'backfill scratch tree\n' >"$SCRATCH/README.md"
+# This runs on a human's machine: a global commit.gpgsign=true would try to
+# sign the scratch commit as backfill@localhost, fail, and end the run before
+# a single release is planned. The scratch commit is never published — unsigned.
+git -C "$SCRATCH" add README.md && git -C "$SCRATCH" -c user.name=backfill -c user.email=backfill@localhost -c commit.gpgsign=false commit -q -m scratch || die2 "could not commit the guard's scratch checkout"   # mutation-anchor: scratch-commit-unsigned
+printf 'README.md\n' >"$TMP/include.txt"
+
+# run_guard ASSETS_DIR OUT_DIR — the guard over ASSETS_DIR. Returns the guard's
+# exit status (0 clean, 1 refused, 2 could not tell); its output is in OUT_DIR.log.
+run_guard() {
+  local assets="$1" out="$2" rc=0
+  local -a args=(--source "$SCRATCH" --include "$TMP/include.txt" --forbidden "$FORBIDDEN_LIST" --out "$out" --assets "$assets")
+  [ -z "${BACKFILL_EXTRA_FORBIDDEN:-}" ] || args+=(--extra-forbidden "$BACKFILL_EXTRA_FORBIDDEN")
+  [ "$STRICT" -eq 0 ] || args+=(--strict)
+  bash "$PUBLISH_GUARD" "${args[@]}" >"$out.log" 2>&1 || rc=$?
+  return "$rc"
+}
+
+# ---- per-release helpers -------------------------------------------------------------------
+# Decision files: one line per asset, `nameactionsha`, action one of
+# upload | skip | compare | refuse. `compare` means the mirror has the asset and
+# the source's digest is unknown, so the download is hashed before deciding.
+count_action() { awk -F'\t' -v a="$2" '$2 == a { n++ } END { print n + 0 }' "$1"; }
+in_sums() { awk -F'\t' -v n="$1" '$2 == n { f = 1 } END { exit !f }' "$R/sums.txt"; }
+sum_of() { awk -F'\t' -v n="$1" '$2 == n { print $1; exit }' "$R/sums.txt"; }
+mirror_digest() { # NAME → sha256 hex; "" when absent; "?" when present without a digest
+  [ "$MREL_PRESENT" -eq 1 ] || return 0
+  awk -F'\t' -v n="$1" '$1 == n { print ($2 == "" ? "?" : $2); exit }' "$R/mirror-assets.tsv"
+}
+# decide LIST NAME EXPECTED_SHA — EXPECTED_SHA may be "" (unknown).
+decide() {
+  local list="$1" name="$2" expected="$3" have
+  have="$(mirror_digest "$name")"
+  if [ "$have" = "?" ]; then die2 "$TAG: mirror asset '$name' has no digest — cannot tell whether it matches"; fi
+  if [ -z "$have" ]; then printf '%s\tupload\t%s\n' "$name" "$expected" >>"$list"; return 0; fi
+  if [ -z "$expected" ]; then printf '%s\tcompare\t%s\n' "$name" "$have" >>"$list"; return 0; fi
+  if [ "$have" = "$expected" ]; then printf '%s\tskip\t%s\n' "$name" "$expected" >>"$list"; return 0; fi   # mutation-anchor: idempotent-skip
+  [ -n "$REFUSAL" ] || REFUSAL="asset '$name' is on the mirror with SHA256 $have but the source release says $expected — a published asset is never replaced"
+  printf '%s\trefuse\t%s\n' "$name" "$expected" >>"$list"
+}
+# resolve_compares LIST DIR — hash each `compare` download; equal → skip,
+# different → REFUSAL (a published asset is never replaced).
+resolve_compares() {
+  local list="$1" dir="$2" aname action have got
+  while IFS=$'\t' read -r aname action have; do
+    [ "$action" = compare ] || continue
+    [ -f "$dir/$aname" ] || die2 "$TAG: '$aname' did not download from '$SRC'"
+    got="$(sha256_of "$dir/$aname")"
+    if [ "$got" = "$have" ]; then
+      awk -F'\t' -v OFS='\t' -v n="$aname" '$1 == n && $2 == "compare" { $2 = "skip" } { print }' "$list" >"$list.new" && mv "$list.new" "$list"
+    else
+      [ -n "$REFUSAL" ] || REFUSAL="asset '$aname' is on the mirror with SHA256 $have but the source's is $got — a published asset is never replaced"
+    fi
+  done <"$list"
+}
+# download_listed LIST DIR — `gh release download` of every upload/compare
+# entry in LIST into DIR (one call; a pattern that matches nothing is an error
+# gh reports, which is could-not-tell here).
+download_listed() {
+  local list="$1" dir="$2" f
+  local -a pats=()
+  while IFS= read -r f; do pats+=(--pattern "$f"); done < <(awk -F'\t' '$2 == "upload" || $2 == "compare" { print $1 }' "$list")
+  [ "${#pats[@]}" -gt 0 ] || return 0
+  gh_read "$dir.log" release download "$TAG" --repo "$SRC" --dir "$dir" "${pats[@]}"
+}
+cols() { # → TEXT_COL / BIN_COL from the decision files
+  local tu ts bu bs cu cs
+  tu="$(count_action "$R/upload-text.txt" upload)"; ts="$(count_action "$R/upload-text.txt" skip)"
+  TEXT_COL="$tu up/$ts skip"
+  BIN_COL="-"
+  [ "$WANT_BIN" -eq 1 ] || return 0
+  bu="$(count_action "$R/upload-bin.txt" upload)"; bs="$(count_action "$R/upload-bin.txt" skip)"
+  cu="$(count_action "$R/upload-companion.txt" upload)"; cs="$(count_action "$R/upload-companion.txt" skip)"
+  BIN_COL="$bu up/$bs skip (+$cu/$cs sig+cert)"
+}
+
+# Table rows: tag | kind | tag-action | release-action | text | binaries | verdict
+: >"$TMP/table.txt"
+N_REFUSED=0; N_CREATED=0; N_SKIPPED=0
+# For the `latest` fallback after the loop: was the newest stable release
+# refused before its own POST (the one carrying make_latest=true), and which
+# stable release did this run create last (= newest, the run is oldest-first).
+NEWEST_STABLE_REFUSED=0; LAST_STABLE_CREATED=""; LAST_STABLE_CREATED_ID=""
+row() { printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$@" >>"$TMP/table.txt"; }
+refuse() { # TAG REASON — the release is refused, the run goes on
+  echo "::error::backfill-releases: REFUSED $1 — $2"
+  # Refusing a release the mirror already has leaves `latest` where it is;
+  # refusing the newest stable BEFORE it is created leaves nothing marked.
+  if [ "$1" = "$NEWEST_STABLE" ] && [ "$REL_ACTION" = create ]; then NEWEST_STABLE_REFUSED=1; fi
+  N_REFUSED=$((N_REFUSED + 1)); cols; row "$1" "$KIND" "$TAG_ACTION" "$REL_ACTION" "$TEXT_COL" "$BIN_COL" refused
+}
+
+# ---- per-release work -------------------------------------------------------------------
+while IFS= read -r TAG; do
+  R="$TMP/r-$TAG"; mkdir -p "$R/text" "$R/bin" "$R/sums" "$R/guard-assets"
+  jq --arg t "$TAG" '.[] | select(.tag_name == $t)' "$TMP/releases.json" >"$R/release.json"
+  # Own assignment, not `[ "$(jq_of …)" = true ]`: inside a test the
+  # substitution's exit 2 is swallowed and a malformed release.json would
+  # silently read as "stable".
+  PRERELEASE="$(jq_of "$R/release.json" '.prerelease')"
+  KIND=stable; [ "$PRERELEASE" = true ] && KIND=prerelease
+  NAME="$(jq_of "$R/release.json" '.name // .tag_name')"
+  CREATED="$(jq_of "$R/release.json" '.created_at')"
+  PUBLISHED="$(jq_of "$R/release.json" '.published_at // .created_at')"
+  jq -r '.body // ""' "$R/release.json" >"$R/body.md"
+  WANT_BIN=0; carries_binaries "$TAG" && WANT_BIN=1
+  TAG_ACTION=create; REL_ACTION=create; REFUSAL=""
+  : >"$R/upload-text.txt"; : >"$R/upload-bin.txt"; : >"$R/upload-companion.txt"
+
+  # Assets, classified. SHA256SUMS is the authority on what a binary is: the
+  # names it lists are binaries, .sig / .cert their companions,
+  # everything else a text asset. A release without SHA256SUMS has no binaries
+  # this tool can vouch for, so every asset is text and none is a binary.
+  jq -r '.assets[] | [.name, (.digest // "")] | @tsv' "$R/release.json" >"$R/assets.tsv"
+  : >"$R/sums.txt"
+  if awk -F'\t' '$1 == "SHA256SUMS" { f = 1 } END { exit !f }' "$R/assets.tsv"; then
+    gh_read "$R/sums.log" release download "$TAG" --repo "$SRC" --dir "$R/sums" --pattern SHA256SUMS
+    [ -s "$R/sums/SHA256SUMS" ] || die2 "$TAG: SHA256SUMS did not download from '$SRC'"
+    awk 'NF >= 2 { print $1 "\t" $NF }' "$R/sums/SHA256SUMS" >"$R/sums.txt"
+  fi
+  : >"$R/text.txt"; : >"$R/bin.txt"; : >"$R/companion.txt"
+  while IFS=$'\t' read -r aname adigest; do
+    if in_sums "$aname"; then printf '%s\t%s\n' "$aname" "$adigest" >>"$R/bin.txt"
+    elif [[ "$aname" =~ $COMPANION_RE ]] && in_sums "${aname%.*}"; then printf '%s\t%s\n' "$aname" "$adigest" >>"$R/companion.txt"
+    else printf '%s\t%s\n' "$aname" "$adigest" >>"$R/text.txt"; fi
+  done <"$R/assets.tsv"
+
+  # -- mirror state for this tag ----------------------------------------------------------
+  jq --arg t "$TAG" '[ .[] | select(.tag_name == $t) ] | .[0] // empty' "$TMP/mirror-releases.json" >"$R/mirror-release.json"
+  MREL_PRESENT=0; [ -s "$R/mirror-release.json" ] && MREL_PRESENT=1
+  : >"$R/mirror-assets.tsv"
+  [ "$MREL_PRESENT" -eq 0 ] || jq -r '.assets[] | [.name, ((.digest // "") | ltrimstr("sha256:"))] | @tsv' "$R/mirror-release.json" >"$R/mirror-assets.tsv"
+  jq --arg r "refs/tags/$TAG" '[ .[] | select(.ref == $r) ] | .[0] // empty' "$TMP/mirror-tags.json" >"$R/mirror-tag.json"
+  MTAG_PRESENT=0; [ -s "$R/mirror-tag.json" ] && MTAG_PRESENT=1
+  if [ "$MTAG_PRESENT" -eq 1 ]; then
+    # The tag exists: it must point at a commit the mirror has. Dereference an
+    # annotated tag first; a dangling tag is refused, never repointed.
+    OBJ_SHA="$(jq_of "$R/mirror-tag.json" '.object.sha')"; OBJ_TYPE="$(jq_of "$R/mirror-tag.json" '.object.type')"
+    if [ "$OBJ_TYPE" = tag ]; then
+      gh_read "$R/mirror-tagobj.json" api "repos/$MIRROR/git/tags/$OBJ_SHA"
+      OBJ_SHA="$(jq_of "$R/mirror-tagobj.json" '.object.sha')"; OBJ_TYPE="$(jq_of "$R/mirror-tagobj.json" '.object.type')"
+    fi
+    if [ "$OBJ_TYPE" != commit ] || ! gh_read_maybe "$R/mirror-tagcommit.json" api "repos/$MIRROR/git/commits/$OBJ_SHA"; then
+      REFUSAL="tag '$TAG' exists on the mirror but points at $OBJ_TYPE $OBJ_SHA, which the mirror does not have — a dangling tag is not repointed"
+    fi
+    TAG_ACTION=present
+  fi
+  [ "$MREL_PRESENT" -eq 0 ] || REL_ACTION=present
+
+  # -- assets: skip / upload / compare / refuse, per asset --------------------------------------
+  # An asset already on the mirror is skipped when its SHA256 equals the
+  # source's, refused when it differs (a published asset is never replaced),
+  # uploaded when absent. The mirror's digest comes from the API; a mirror
+  # asset without one cannot be compared, and "cannot compare" is not "equal".
+  while IFS=$'\t' read -r aname adigest; do decide "$R/upload-text.txt" "$aname" "${adigest#sha256:}"; done <"$R/text.txt"
+  if [ "$WANT_BIN" -eq 1 ]; then
+    while IFS=$'\t' read -r aname _; do decide "$R/upload-bin.txt" "$aname" "$(sum_of "$aname")"; done <"$R/bin.txt"
+    while IFS=$'\t' read -r aname adigest; do decide "$R/upload-companion.txt" "$aname" "${adigest#sha256:}"; done <"$R/companion.txt"
+  fi
+  if [ -n "$REFUSAL" ]; then refuse "$TAG" "$REFUSAL"; continue; fi
+  N_TODO="$(cat "$R"/upload-*.txt | awk -F'\t' '$2 == "upload" || $2 == "compare" { n++ } END { print n + 0 }')"
+  if [ "$TAG_ACTION" = present ] && [ "$REL_ACTION" = present ] && [ "$N_TODO" -eq 0 ]; then
+    note "$TAG: already on the mirror, every asset matches — nothing to do"
+    N_SKIPPED=$((N_SKIPPED + 1)); cols; row "$TAG" "$KIND" present present "$TEXT_COL" "$BIN_COL" skipped; continue
+  fi
+
+  # -- downloads ------------------------------------------------------------------------------
+  # Text assets to upload or compare come down in both modes (the guard reads
+  # them). Binaries and companions come down under --apply only: they are large
+  # and opaque to the scan; each binary is checked against SHA256SUMS at once.
+  download_listed "$R/upload-text.txt" "$R/text"
+  resolve_compares "$R/upload-text.txt" "$R/text"
+  if [ -n "$REFUSAL" ]; then refuse "$TAG" "$REFUSAL"; continue; fi
+  if [ "$APPLY" -eq 1 ] && [ "$WANT_BIN" -eq 1 ]; then
+    cat "$R/upload-bin.txt" "$R/upload-companion.txt" >"$R/upload-binlike.txt"
+    download_listed "$R/upload-binlike.txt" "$R/bin"
+    while IFS=$'\t' read -r aname action expected; do
+      [ "$action" = upload ] || continue
+      [ -f "$R/bin/$aname" ] || die2 "$TAG: binary '$aname' did not download from '$SRC'"
+      got="$(sha256_of "$R/bin/$aname")"
+      [ "$got" = "$expected" ] || { REFUSAL="binary '$aname' hashes to $got but the source release's SHA256SUMS says $expected — not uploaded"; break; }   # mutation-anchor: sha-check
+    done <"$R/upload-bin.txt"
+    if [ -n "$REFUSAL" ]; then refuse "$TAG" "$REFUSAL"; continue; fi
+    resolve_compares "$R/upload-companion.txt" "$R/bin"
+    if [ -n "$REFUSAL" ]; then refuse "$TAG" "$REFUSAL"; continue; fi
+  fi
+
+  # -- notes --------------------------------------------------------------------------------
+  if [ "$REL_ACTION" = create ]; then
+    if [ "$NOTES_MODE" = fixed ]; then
+      {
+        echo "tracebloc CLI $TAG."
+        echo
+        echo "Install with the one-liner in the README, or download a binary below and"
+        echo "verify it against SHA256SUMS and its cosign .sig/.cert (recipe in the README)."
+      } >"$R/notes.md"
+    else
+      cp "$R/body.md" "$R/notes.md"
+    fi
+    {
+      echo
+      echo "---"
+      echo "Backfilled release marker: originally published $PUBLISHED. The tag \`$TAG\` on this repository points at the default branch, not at the sources this release was built from."
+      if [ "$WANT_BIN" -eq 0 ]; then
+        echo "Binaries are carried only for the newest $BINARY_KEEP releases; re-run the installer to get a current build."
+      fi
+    } >>"$R/notes.md"
+  fi
+
+  # -- the guard: text assets, companions and notes, before any write --------------------------
+  while IFS= read -r f; do cp "$R/text/$f" "$R/guard-assets/$f"; done < <(awk -F'\t' '$2 == "upload" { print $1 }' "$R/upload-text.txt")
+  if [ "$APPLY" -eq 1 ] && [ "$WANT_BIN" -eq 1 ]; then
+    while IFS= read -r f; do cp "$R/bin/$f" "$R/guard-assets/$f"; done < <(awk -F'\t' '$2 == "upload" { print $1 }' "$R/upload-companion.txt")
+  fi
+  [ "$REL_ACTION" != create ] || cp "$R/notes.md" "$R/guard-assets/RELEASE_NOTES.md"
+  if [ -n "$(ls -A "$R/guard-assets")" ]; then
+    rc=0; run_guard "$R/guard-assets" "$R/guard-out" || rc=$?
+    case "$rc" in
+      0) ;;
+      1) REFUSAL="the guard refused the notes or a text asset: $(grep -E 'REFUSED' "$R/guard-out.log" | sed 's/^::error::publish-guard: //' | paste -sd';' -)" ;;   # mutation-anchor: guard-refusal
+      *) cat "$R/guard-out.log"; die2 "$TAG: the guard could not tell (exit $rc)" ;;
+    esac
+    if [ -n "$REFUSAL" ]; then grep -E 'REFUSED|^    ' "$R/guard-out.log" | sed 's/^/    /'; refuse "$TAG" "$REFUSAL"; continue; fi
+  fi
+
+  cols
+  if [ "$WANT_BIN" -eq 1 ]; then BIN_PLAN="$BIN_COL"; else BIN_PLAN="none (older than the newest $BINARY_KEEP)"; fi
+  PLAN="tag: $TAG_ACTION | release: $REL_ACTION | text: $TEXT_COL | binaries: $BIN_PLAN"
+  if [ "$APPLY" -eq 0 ]; then
+    note "$TAG [$KIND] would: $PLAN"
+    N_CREATED=$((N_CREATED + 1)); row "$TAG" "$KIND" "$TAG_ACTION" "$REL_ACTION" "$TEXT_COL" "$BIN_COL" planned; continue
+  fi
+
+  # -- writes -----------------------------------------------------------------------------------
+  note "$TAG [$KIND]: $PLAN"
+  if [ "$TAG_ACTION" = create ]; then
+    # The original tag's date and message, when it is annotated; the release's
+    # created_at otherwise. Read from the source, never invented.
+    gh_read "$R/src-ref.json" api "repos/$SRC/git/ref/tags/$TAG"
+    SRC_OBJ_TYPE="$(jq_of "$R/src-ref.json" '.object.type')"; SRC_OBJ_SHA="$(jq_of "$R/src-ref.json" '.object.sha')"
+    TAG_DATE="$CREATED"; ORIG_MSG=""
+    if [ "$SRC_OBJ_TYPE" = tag ]; then
+      gh_read "$R/src-tagobj.json" api "repos/$SRC/git/tags/$SRC_OBJ_SHA"
+      TAG_DATE="$(jq_of "$R/src-tagobj.json" '.tagger.date // empty')"; [ -n "$TAG_DATE" ] || TAG_DATE="$CREATED"
+      ORIG_MSG="$(jq_of "$R/src-tagobj.json" '.message // ""')"
+    fi
+    {
+      echo "Release $TAG"
+      echo
+      echo "Mirror release marker for $TAG: this tag points at the mirror's default-branch head, not at the sources the release was built from. Original tag date: $TAG_DATE."
+      if [ -n "$ORIG_MSG" ]; then echo; echo "--- original tag message ---"; printf '%s\n' "$ORIG_MSG"; fi
+    } >"$R/tag-message.txt"
+    gh_write "$R/tagobj.json" api -X POST "repos/$MIRROR/git/tags" \
+      -f "tag=$TAG" -F "message=@$R/tag-message.txt" -f "object=$MIRROR_HEAD" -f type=commit \
+      -f "tagger[name]=${PUBLISH_MIRROR_GIT_NAME:-github-actions[bot]}" \
+      -f "tagger[email]=${PUBLISH_MIRROR_GIT_EMAIL:-github-actions[bot]@users.noreply.github.com}" \
+      -f "tagger[date]=$TAG_DATE"
+    TAGOBJ_SHA="$(jq_of "$R/tagobj.json" '.sha')"
+    [[ "$TAGOBJ_SHA" =~ ^[0-9a-f]{40}$ ]] || die2 "$TAG: the created tag object has no sha"
+    gh_write "$R/ref.json" api -X POST "repos/$MIRROR/git/refs" -f "ref=refs/tags/$TAG" -f "sha=$TAGOBJ_SHA"
+  fi
+  if [ "$REL_ACTION" = create ]; then
+    LATEST=false; [ "$TAG" = "$NEWEST_STABLE" ] && LATEST=true
+    PRE=false; [ "$KIND" = prerelease ] && PRE=true
+    gh_write "$R/created.json" api -X POST "repos/$MIRROR/releases" \
+      -f "tag_name=$TAG" -f "name=$NAME" -F "body=@$R/notes.md" -F "prerelease=$PRE" -F draft=false -f "make_latest=$LATEST"
+  fi
+  UPLOADS=()
+  while IFS= read -r f; do UPLOADS+=("$f"); done < <(
+    awk -F'\t' -v d="$R/text" '$2 == "upload" { print d "/" $1 }' "$R/upload-text.txt"
+    awk -F'\t' -v d="$R/bin"  '$2 == "upload" { print d "/" $1 }' "$R/upload-bin.txt" "$R/upload-companion.txt"
+  )
+  if [ "${#UPLOADS[@]}" -gt 0 ]; then
+    gh_write "$R/upload.log" release upload "$TAG" "${UPLOADS[@]}" --repo "$MIRROR"
+  fi
+  if [ "$KIND" = stable ] && [ "$REL_ACTION" = create ]; then
+    LAST_STABLE_CREATED="$TAG"; LAST_STABLE_CREATED_ID="$(jq_of "$R/created.json" '.id')"
+    [[ "$LAST_STABLE_CREATED_ID" =~ ^[0-9]+$ ]] || die2 "$TAG: the created release has no numeric id"
+  fi
+  N_CREATED=$((N_CREATED + 1))
+  row "$TAG" "$KIND" "$TAG_ACTION" "$REL_ACTION" "$TEXT_COL" "$BIN_COL" "done"
+done <"$TMP/tags-run.txt"
+
+# ---- latest, when the newest stable release was refused -----------------------------
+# make_latest=true travels on the newest stable release's own POST; every older
+# release is created with make_latest=false. Refused before that POST, the newest
+# stable leaves the mirror's releases/latest answering 404 until a human re-runs
+# --only-tag for it. Until then the newest stable release this run DID write is
+# marked latest — that re-run's POST moves `latest` forward again.
+if [ "$APPLY" -eq 1 ] && [ "$NEWEST_STABLE_REFUSED" -eq 1 ]; then   # mutation-anchor: latest-fallback
+  if [ -n "$LAST_STABLE_CREATED" ]; then
+    note "latest: $NEWEST_STABLE was refused — marking $LAST_STABLE_CREATED, the newest stable release written in this run, as latest until $NEWEST_STABLE is re-run"
+    # -f, not -F: make_latest is a STRING enum ("true"/"false"/"legacy") in the
+    # releases API; a typed boolean is a 422, which here would be a die2 in the
+    # very case this fallback exists for. Same reason the create path uses -f.
+    gh_write "$TMP/latest.json" api -X PATCH "repos/$MIRROR/releases/$LAST_STABLE_CREATED_ID" -f make_latest=true   # mutation-anchor: latest-string-typed
+  else
+    echo "::warning::backfill-releases: $NEWEST_STABLE was refused and this run wrote no stable release — nothing is newly marked latest; re-run --only-tag $NEWEST_STABLE once the refusal is fixed"
+  fi
+fi
+
+# ---- report -------------------------------------------------------------------------
+echo
+echo "backfill-releases: $MODE report — $SRC → $MIRROR"
+{
+  printf 'TAG\tKIND\tTAG-ON-MIRROR\tRELEASE\tTEXT ASSETS\tBINARIES\tVERDICT\n'
+  cat "$TMP/table.txt"
+} | column -t -s "$(printf '\t')" 2>/dev/null || cat "$TMP/table.txt"
+echo
+VERB=written; [ "$APPLY" -eq 1 ] || VERB=planned
+echo "backfill-releases: $N_RUN release(s) in this run — $N_CREATED $VERB, $N_SKIPPED already complete, $N_REFUSED refused"
+if [ "$N_REFUSED" -gt 0 ]; then
+  echo "::error::backfill-releases: REFUSED — $N_REFUSED release(s) were refused (see the table); the rest went ahead"
+  exit 1
+fi
+exit 0
diff --git a/scripts/tests/backfill-releases-verify.sh b/scripts/tests/backfill-releases-verify.sh
new file mode 100644
index 0000000..2b573e4
--- /dev/null
+++ b/scripts/tests/backfill-releases-verify.sh
@@ -0,0 +1,570 @@
+#!/usr/bin/env bash
+# =============================================================================
+#  backfill-releases-verify.sh — pin the properties of
+#  scripts/backfill-releases.sh, the one-shot historical backfill of releases
+#  to the public mirror.
+#
+#  Offline. `gh` is a recording FAKE on PATH that serves a source repository's
+#  releases, tags and assets from a fixture directory and keeps the mirror's
+#  state (tags, releases, uploaded assets with their digests) in JSON files it
+#  mutates on every write, so a second run sees what the first one created.
+#  Every call is logged; a WRITE is any `api -X POST` or `release upload` line.
+#  gitleaks is a stub (the guard needs one on PATH; the real scanner is the
+#  workflow's business).
+#
+#  Pinned: dry-run writes nothing; --apply makes exactly the expected writes,
+#  oldest release first, newest stable release last and marked latest; a second
+#  --apply writes nothing; the BINARY_KEEP boundary (the 10th newest carries
+#  binaries, the 11th does not — and the count is over the FILTERED list, so
+#  --include-prerelease shifts it); a binary whose SHA256 disagrees with the
+#  source's SHA256SUMS is refused by name and nothing of that release is
+#  written; an unset mirror and a mirror equal to the source are refused by
+#  publish-mirror's rule; prereleases are excluded by default; a read that
+#  fails is could-not-tell (exit 2) with the failing call named, never an empty
+#  list, and a read that returns non-JSON is could-not-tell naming the file and
+#  filter even though it is parsed inside a "$(...)" substitution (the reason
+#  travels on stderr, so the variable never swallows it); the DEFAULT notes are the workflow's fixed text (the source body is
+#  never written or scanned unless --notes source asks for it); under --notes
+#  source a refuse-tier needle in a release body refuses that release naming
+#  the tier; a mirror asset already present with a different digest is refused,
+#  never replaced; a mirror tag that dangles is refused, never repointed;
+#  --only-tag / --from-tag narrow the run without changing the binary decision;
+#  the mirror tag carries the original date and, for an annotated source tag,
+#  the original message; when the newest stable release is refused, the newest
+#  stable release the run did write is marked latest (one PATCH) so
+#  releases/latest never 404s behind a refusal; the guard's scratch commit is
+#  made unsigned, so a global commit.gpgsign cannot end the run.
+#
+#  --mutations: copies the script, breaks ONE rule per copy at its
+#  `# mutation-anchor: NAME` line, proves the mutation landed (the anchor was
+#  found exactly once and the copy differs), runs THIS suite against the copy
+#  and demands red. A mutation the suite survives is a vacuous test, reported
+#  as a failure of this harness.
+#
+#  Environment (mutation runs only): BACKFILL_UNDER_TEST names the script copy
+#  to test instead of ../backfill-releases.sh.
+# =============================================================================
+set -uo pipefail
+
+SELF="$(cd "$(dirname "$0")" && pwd)/$(basename "$0")"
+SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
+SCRIPTS_DIR="$(cd "$SELF_DIR/.." && pwd)"
+REAL="$SCRIPTS_DIR/backfill-releases.sh"
+BACKFILL="${BACKFILL_UNDER_TEST:-$REAL}"
+[ -f "$REAL" ] || { printf 'backfill-releases-verify: %s missing — refusing to report clean\n' "$REAL" >&2; exit 2; }
+[ -f "$BACKFILL" ] || { printf 'backfill-releases-verify: %s missing — refusing to report clean\n' "$BACKFILL" >&2; exit 2; }
+for t in jq git awk; do command -v "$t" >/dev/null 2>&1 || { printf 'backfill-releases-verify: %s is not on PATH — refusing to report clean\n' "$t" >&2; exit 2; }; done
+
+PASS=0
+FAIL=0
+ok()  { printf '  ok   %s\n' "$1"; PASS=$((PASS+1)); }
+bad() { printf '  FAIL %s\n' "$1"; FAIL=$((FAIL+1)); }
+
+ROOT="$(mktemp -d "${TMPDIR:-/tmp}/backfill-releases-verify.XXXXXX")"
+trap 'rm -rf "$ROOT"' EXIT
+if command -v sha256sum >/dev/null 2>&1; then sha256_of() { sha256sum "$1" | cut -d' ' -f1; }; else sha256_of() { shasum -a 256 "$1" | cut -d' ' -f1; }; fi
+
+# =============================================================================
+#  --mutations mode: break one rule per copy, demand red.
+# =============================================================================
+if [ "${1:-}" = "--mutations" ]; then
+  echo "== backfill-releases.sh mutations =="
+  # NAME|REPLACEMENT — the line carrying `# mutation-anchor: NAME` becomes REPLACEMENT.
+  # Each replacement is a valid program that drops exactly the rule the anchor names.
+  MUTATIONS=(
+    'releases-read-fail-closed|gh api --paginate "repos/$SRC/releases" >"$TMP/src-pages.json" 2>/dev/null || printf "[]" >"$TMP/src-pages.json"'
+    'prerelease-filter|FILTER='"'"'[ .[] | select(.draft == false) ] | sort_by(.created_at) | reverse'"'"
+    'binary-keep|cp "$TMP/tags-newest-first.txt" "$TMP/tags-with-binaries.txt"'
+    'idempotent-skip|:'
+    'sha-check|:'
+    'guard-refusal|      1) ;;'
+    'notes-default-fixed|APPLY=0; FROM_TAG=""; ONLY_TAG=""; INCLUDE_PRE=0; NOTES_MODE=source; STRICT=0'
+    'die2-stderr|die2() { echo "::error::backfill-releases: COULD NOT TELL — $1 (nothing more is written)"; exit 2; }'
+    'latest-fallback|if false; then'
+    'latest-string-typed|    gh_write "$TMP/latest.json" api -X PATCH "repos/$MIRROR/releases/$LAST_STABLE_CREATED_ID" -F make_latest=true'
+    'scratch-commit-unsigned|git -C "$SCRATCH" add README.md && git -C "$SCRATCH" -c user.name=backfill -c user.email=backfill@localhost commit -q -m scratch || die2 "could not commit the guard'"'"'s scratch checkout"'
+  )
+  MUT_PASS=0; MUT_FAIL=0
+  # Every mutant is prepared and PROVEN to have landed first; then the suite
+  # runs against all of them in parallel (each run is ~a minute of forks).
+  NAMES=()
+  for entry in "${MUTATIONS[@]}"; do
+    name="${entry%%|*}"; repl="${entry#*|}"
+    copy="$ROOT/mutant-$name.sh"
+    n="$(grep -c -- "# mutation-anchor: $name\$" "$REAL")"
+    if [ "$n" -ne 1 ]; then bad "mutation $name: anchor found $n time(s) in $REAL, need exactly 1"; MUT_FAIL=$((MUT_FAIL+1)); continue; fi
+    awk -v a="# mutation-anchor: $name" -v r="$repl" 'index($0, a) && substr($0, length($0) - length(a) + 1) == a { print r; next } { print }' "$REAL" >"$copy"
+    if cmp -s "$REAL" "$copy"; then bad "mutation $name: the copy is identical to the original — the mutation did not apply"; MUT_FAIL=$((MUT_FAIL+1)); continue; fi
+    if ! bash -n "$copy" 2>"$ROOT/mutant.err"; then bad "mutation $name: the mutant does not parse: $(cat "$ROOT/mutant.err")"; MUT_FAIL=$((MUT_FAIL+1)); continue; fi
+    NAMES+=("$name")
+  done
+  for name in "${NAMES[@]+"${NAMES[@]}"}"; do
+    ( if BACKFILL_UNDER_TEST="$ROOT/mutant-$name.sh" bash "$SELF" >"$ROOT/mutant-$name.log" 2>&1; then echo green; else echo red; fi >"$ROOT/mutant-$name.verdict" ) &
+  done
+  wait
+  for name in "${NAMES[@]+"${NAMES[@]}"}"; do
+    out="$ROOT/mutant-$name.log"
+    if [ "$(cat "$ROOT/mutant-$name.verdict" 2>/dev/null)" = red ]; then
+      ok "mutation $name: caught — $(grep -c '^  FAIL' "$out") test(s) reddened: $(grep '^  FAIL' "$out" | head -2 | sed -E 's/^  FAIL ([^(:]*).*/\1/' | paste -sd'|' -)"; MUT_PASS=$((MUT_PASS+1))
+    else
+      bad "mutation $name: the suite stayed GREEN against the mutant — a test is vacuous"; MUT_FAIL=$((MUT_FAIL+1))
+      sed 's/^/      /' "$out" | tail -5
+    fi
+  done
+  echo
+  printf 'backfill-releases-verify --mutations: %d caught, %d survived\n' "$MUT_PASS" "$MUT_FAIL"
+  [ "$MUT_FAIL" -eq 0 ] && [ "$MUT_PASS" -ge 4 ]
+  exit $?
+fi
+
+# =============================================================================
+#  The fake gh and the gitleaks stub
+# =============================================================================
+SHIM="$ROOT/shim"; mkdir -p "$SHIM"
+cat >"$SHIM/gitleaks" <<'EOF'
+#!/usr/bin/env bash
+[ "${1:-}" = version ] && { echo "gitleaks-stub"; exit 0; }
+exit 0
+EOF
+cat >"$SHIM/gh" <<'EOF'
+#!/usr/bin/env bash
+# Recording fake gh. FIX: fixtures (read-only). STATE: the mirror, mutated by writes.
+set -uo pipefail
+FIX="${FAKE_GH_FIX:?}"; STATE="${FAKE_GH_STATE:?}"
+printf '%s\n' "$*" >>"${GH_LOG:?}"
+if [ -n "${FAKE_GH_FAIL_RE:-}" ] && [[ "$*" =~ $FAKE_GH_FAIL_RE ]]; then echo "gh: Internal Server Error (HTTP 500)" >&2; exit 1; fi
+# A call that "succeeds" with a body that is not JSON — the answer jq must refuse.
+if [ -n "${FAKE_GH_GARBLE_RE:-}" ] && [[ "$*" =~ $FAKE_GH_GARBLE_RE ]]; then echo 'not json'; exit 0; fi
+SRC="$(cat "$FIX/src-repo")"; MIRROR="$(cat "$FIX/mirror-repo")"; HEAD_SHA="$(cat "$FIX/mirror-head")"
+if command -v sha256sum >/dev/null 2>&1; then sha() { sha256sum "$1" | cut -d' ' -f1; }; else sha() { shasum -a 256 "$1" | cut -d' ' -f1; }; fi
+fake_sha() { printf '%s' "$1" | { command -v sha256sum >/dev/null 2>&1 && sha256sum || shasum -a 256; } | cut -c1-40; }   # a 40-hex git object id
+notfound() { echo "gh: Not Found (HTTP 404)" >&2; exit 1; }
+fieldval() { # KEY from -f/-F pairs; @file is read
+  local k="$1" f v
+  for f in "${FIELDS[@]+"${FIELDS[@]}"}"; do
+    case "$f" in "$k="*) v="${f#*=}"; case "$v" in @*) cat "${v#@}" ;; *) printf '%s' "$v" ;; esac; return 0 ;; esac
+  done
+  return 1
+}
+# make_latest is a STRING enum ("true"/"false"/"legacy") in the releases API. A
+# `-F make_latest=true` is a JSON boolean, which GitHub answers with 422 — so
+# does this fake, on both release calls, instead of quietly accepting it.
+make_latest_typed() { local t; for t in "${TYPED[@]+"${TYPED[@]}"}"; do [ "$t" = make_latest ] && return 0; done; return 1; }
+reject_typed_make_latest() { ! make_latest_typed || { echo "gh: HTTP 422: Invalid request. For 'properties/make_latest', true is not a string. (https://docs.github.com/rest/releases/releases)" >&2; exit 1; }; }
+cmd="${1:-}"; shift || true
+case "$cmd" in
+  repo)
+    printf '{"nameWithOwner":"%s"}\n' "$SRC" ;;
+  api)
+    METHOD=GET; PATHP=""; FIELDS=(); TYPED=()
+    while [ "$#" -gt 0 ]; do
+      case "$1" in
+        -X) METHOD="$2"; shift 2 ;;
+        --paginate) shift ;;
+        -f) FIELDS+=("$2"); shift 2 ;;
+        -F) FIELDS+=("$2"); TYPED+=("${2%%=*}"); shift 2 ;;   # -F types true/false/numbers as JSON, like real gh
+        *) PATHP="$1"; shift ;;
+      esac
+    done
+    case "$METHOD $PATHP" in
+      "GET repos/$SRC/releases")
+        # Two "pages": the fixture is split so --paginate's concatenated-arrays shape is exercised.
+        jq -c '.[0:7]' "$FIX/src-releases.json"; jq -c '.[7:]' "$FIX/src-releases.json" ;;
+      "GET repos/$SRC/git/ref/tags/"*)
+        t="${PATHP##*/}"; jq -e --arg r "refs/tags/$t" '.[] | select(.ref == $r)' "$FIX/src-tags.json" >/dev/null || notfound
+        jq --arg r "refs/tags/$t" '.[] | select(.ref == $r)' "$FIX/src-tags.json" ;;
+      "GET repos/$SRC/git/tags/"*)
+        s="${PATHP##*/}"; jq -e --arg s "$s" '.[] | select(.sha == $s)' "$FIX/src-tagobjs.json" >/dev/null || notfound
+        jq --arg s "$s" '.[] | select(.sha == $s)' "$FIX/src-tagobjs.json" ;;
+      "GET repos/$MIRROR")
+        printf '{"full_name":"%s","default_branch":"main","visibility":"public"}\n' "$MIRROR" ;;
+      "GET repos/$MIRROR/commits/main")
+        [ -z "${FAKE_GH_EMPTY_MIRROR:-}" ] || { echo "gh: Git Repository is empty. (HTTP 409)" >&2; exit 1; }
+        printf '{"sha":"%s"}\n' "$HEAD_SHA" ;;
+      "GET repos/$MIRROR/releases")
+        cat "$STATE/mirror-releases.json" ;;
+      "GET repos/$MIRROR/git/matching-refs/tags/")
+        cat "$STATE/mirror-tags.json" ;;
+      "GET repos/$MIRROR/git/commits/"*)
+        s="${PATHP##*/}"; [ "$s" = "$HEAD_SHA" ] || notfound; printf '{"sha":"%s"}\n' "$s" ;;
+      "GET repos/$MIRROR/git/tags/"*)
+        s="${PATHP##*/}"; jq -e --arg s "$s" '.[] | select(.sha == $s)' "$STATE/mirror-tagobjs.json" >/dev/null || notfound
+        jq --arg s "$s" '.[] | select(.sha == $s)' "$STATE/mirror-tagobjs.json" ;;
+      "POST repos/$MIRROR/git/tags")
+        tag="$(fieldval tag)"; msg="$(fieldval message)"; obj="$(fieldval object)"; date="$(fieldval 'tagger[date]')"
+        s="$(fake_sha "tagobj:$tag")"
+        jq --arg s "$s" --arg tag "$tag" --arg msg "$msg" --arg obj "$obj" --arg date "$date" \
+          '. + [{sha: $s, tag: $tag, message: $msg, tagger: {date: $date}, object: {sha: $obj, type: "commit"}}]' "$STATE/mirror-tagobjs.json" >"$STATE/t.json" && mv "$STATE/t.json" "$STATE/mirror-tagobjs.json"
+        printf '{"sha":"%s"}\n' "$s" ;;
+      "POST repos/$MIRROR/git/refs")
+        ref="$(fieldval ref)"; s="$(fieldval sha)"
+        jq -e --arg r "$ref" '.[] | select(.ref == $r)' "$STATE/mirror-tags.json" >/dev/null && { echo "gh: Reference already exists (HTTP 422)" >&2; exit 1; }
+        jq --arg r "$ref" --arg s "$s" '. + [{ref: $r, object: {sha: $s, type: "tag"}}]' "$STATE/mirror-tags.json" >"$STATE/t.json" && mv "$STATE/t.json" "$STATE/mirror-tags.json"
+        printf '{"ref":"%s"}\n' "$ref" ;;
+      "POST repos/$MIRROR/releases")
+        reject_typed_make_latest
+        tag="$(fieldval tag_name)"; name="$(fieldval name)"; body="$(fieldval body)"; pre="$(fieldval prerelease)"; latest="$(fieldval make_latest)"
+        jq -e --arg r "refs/tags/$tag" '.[] | select(.ref == $r)' "$STATE/mirror-tags.json" >/dev/null || { echo "gh: fake: release for '$tag' before its tag (HTTP 422)" >&2; exit 1; }
+        id="$(jq 'length + 1' "$STATE/mirror-releases.json")"
+        jq --argjson id "$id" --arg tag "$tag" --arg name "$name" --arg body "$body" --arg pre "$pre" --arg latest "$latest" \
+          '. + [{id: $id, tag_name: $tag, name: $name, body: $body, prerelease: ($pre == "true"), make_latest: $latest, draft: false, assets: []}]' "$STATE/mirror-releases.json" >"$STATE/t.json" && mv "$STATE/t.json" "$STATE/mirror-releases.json"
+        printf '{"id":%s,"tag_name":"%s"}\n' "$id" "$tag" ;;
+      "PATCH repos/$MIRROR/releases/"*)
+        reject_typed_make_latest
+        id="${PATHP##*/}"; latest="$(fieldval make_latest)"
+        [[ "$id" =~ ^[0-9]+$ ]] || notfound
+        jq -e --argjson id "$id" '.[] | select(.id == $id)' "$STATE/mirror-releases.json" >/dev/null || notfound
+        jq --argjson id "$id" --arg latest "$latest" 'map(if .id == $id then .make_latest = $latest else . end)' "$STATE/mirror-releases.json" >"$STATE/t.json" && mv "$STATE/t.json" "$STATE/mirror-releases.json"
+        printf '{"id":%s}\n' "$id" ;;
+      *) echo "gh: fake: unhandled $METHOD $PATHP" >&2; exit 1 ;;
+    esac ;;
+  release)
+    sub="${1:-}"; shift || true
+    case "$sub" in
+      download)
+        tag="$1"; shift; repo=""; dir=""; pats=()
+        while [ "$#" -gt 0 ]; do case "$1" in --repo) repo="$2"; shift 2 ;; --dir) dir="$2"; shift 2 ;; --pattern) pats+=("$2"); shift 2 ;; *) echo "gh: fake: unknown download arg $1" >&2; exit 1 ;; esac; done
+        [ "$repo" = "$SRC" ] || { echo "gh: fake: download from '$repo' is not the source" >&2; exit 1; }
+        mkdir -p "$dir"
+        for p in "${pats[@]}"; do
+          [ -f "$FIX/assets/$tag/$p" ] || { echo "gh: no assets match the file pattern ($p)" >&2; exit 1; }
+          [ ! -e "$dir/$p" ] || { echo "gh: fake: $dir/$p already exists (gh refuses without --clobber)" >&2; exit 1; }
+          cp "$FIX/assets/$tag/$p" "$dir/$p"
+        done ;;
+      upload)
+        tag="$1"; shift; repo=""; files=()
+        while [ "$#" -gt 0 ]; do case "$1" in --repo) repo="$2"; shift 2 ;; *) files+=("$1"); shift ;; esac; done
+        [ "$repo" = "$MIRROR" ] || { echo "gh: fake: upload to '$repo' is not the mirror" >&2; exit 1; }
+        jq -e --arg t "$tag" '.[] | select(.tag_name == $t)' "$STATE/mirror-releases.json" >/dev/null || { echo "gh: release not found" >&2; exit 1; }
+        for f in "${files[@]}"; do
+          [ -f "$f" ] || { echo "gh: fake: $f is not a file" >&2; exit 1; }
+          n="$(basename "$f")"; d="$(sha "$f")"
+          jq -e --arg t "$tag" --arg n "$n" '.[] | select(.tag_name == $t) | .assets[] | select(.name == $n)' "$STATE/mirror-releases.json" >/dev/null && { echo "gh: fake: asset '$n' already on '$tag' (HTTP 422)" >&2; exit 1; }
+          jq --arg t "$tag" --arg n "$n" --arg d "sha256:$d" 'map(if .tag_name == $t then .assets += [{name: $n, digest: $d}] else . end)' "$STATE/mirror-releases.json" >"$STATE/t.json" && mv "$STATE/t.json" "$STATE/mirror-releases.json"
+        done ;;
+      *) echo "gh: fake: unhandled release $sub" >&2; exit 1 ;;
+    esac ;;
+  *) echo "gh: fake: unhandled command $cmd" >&2; exit 1 ;;
+esac
+EOF
+chmod +x "$SHIM/gh" "$SHIM/gitleaks"
+
+# =============================================================================
+#  Fixtures: 12 stable releases v0.1.0..v0.1.11 (created a day apart) and one
+#  newer prerelease v0.1.12-rc.1. Each carries install.sh, install.ps1,
+#  SHA256SUMS, two "binaries" and their .sig/.cert. v0.1.3's source tag is
+#  annotated. The fixture list is deliberately NOT in date order.
+# =============================================================================
+STABLE=(v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9 v0.1.10 v0.1.11)
+PRE=v0.1.12-rc.1
+ALL=("${STABLE[@]}" "$PRE")
+HEAD_SHA=1111111111111111111111111111111111111111
+ANNOT_TAG=v0.1.3; ANNOT_DATE=2025-12-31T10:00:00Z; ANNOT_MSG="tracebloc CLI v0.1.3 original annotation"
+
+# build_fixtures DIR [CORRUPT_SUMS_TAG] [BAD_BODY_TAG]
+build_fixtures() {
+  local fix="$1" corrupt="${2:-}" badbody="${3:-}" i tag pre body created f
+  mkdir -p "$fix/assets"
+  printf 'acme/src' >"$fix/src-repo"; printf 'acme/mirror' >"$fix/mirror-repo"; printf '%s' "$HEAD_SHA" >"$fix/mirror-head"
+  : >"$fix/releases.ndjson"; : >"$fix/tags.ndjson"; printf '[]' >"$fix/src-tagobjs.json"
+  i=0
+  for tag in "${ALL[@]}"; do
+    i=$((i + 1)); mkdir -p "$fix/assets/$tag"
+    printf '#!/bin/sh\necho install %s\n' "$tag" >"$fix/assets/$tag/install.sh"
+    printf 'Write-Host install %s\n' "$tag" >"$fix/assets/$tag/install.ps1"
+    for f in "tracebloc-$tag-linux-amd64" "tracebloc-$tag-darwin-arm64"; do
+      printf 'binary %s\n' "$f" >"$fix/assets/$tag/$f"
+      printf 'MEUCIQ%s\n' "$f" >"$fix/assets/$tag/$f.sig"
+      printf -- '-----BEGIN CERTIFICATE-----\n%s\n-----END CERTIFICATE-----\n' "$f" >"$fix/assets/$tag/$f.cert"
+    done
+    ( cd "$fix/assets/$tag" && for f in tracebloc-*; do case "$f" in *.sig|*.cert) ;; *) printf '%s  %s\n' "$(sha256_of "$f")" "$f" ;; esac; done ) >"$fix/assets/$tag/SHA256SUMS"
+    if [ "$tag" = "$corrupt" ]; then
+      awk -v n="tracebloc-$tag-darwin-arm64" '$2 == n { $1 = "0000000000000000000000000000000000000000000000000000000000000000" } { print $1 "  " $2 }' "$fix/assets/$tag/SHA256SUMS" >"$fix/s" && mv "$fix/s" "$fix/assets/$tag/SHA256SUMS"
+    fi
+    pre=false; [ "$tag" = "$PRE" ] && pre=true
+    created="$(printf '2026-01-%02dT12:00:00Z' "$i")"
+    body="## What's Changed\n* fix: something in $tag by @dev in https://github.com/acme/src/pull/$i"
+    [ "$tag" != "$badbody" ] || body="$body\n* ops: moved to role arn:aws:iam::000000000000:role/planted"
+    ( cd "$fix/assets/$tag" && for f in *; do printf '%s\t%s\t%s\n' "$f" "sha256:$(sha256_of "$f")" "$(wc -c <"$f" | tr -d ' ')"; done ) \
+      | jq -R -s -c --arg tag "$tag" --arg pre "$pre" --arg created "$created" --arg body "$(printf "$body")" '
+          split("\n") | map(select(length > 0) | split("\t") | {name: .[0], digest: .[1], size: (.[2]|tonumber)}) as $assets
+          | {tag_name: $tag, name: $tag, body: $body, draft: false, prerelease: ($pre == "true"), created_at: $created, published_at: $created, target_commitish: "develop", assets: $assets}' >>"$fix/releases.ndjson"
+    if [ "$tag" = "$ANNOT_TAG" ]; then
+      jq -n -c --arg t "$tag" '{ref: ("refs/tags/" + $t), object: {sha: "3333333333333333333333333333333333333333", type: "tag"}}' >>"$fix/tags.ndjson"
+      jq -n --arg m "$ANNOT_MSG" --arg d "$ANNOT_DATE" '[{sha: "3333333333333333333333333333333333333333", message: $m, tagger: {name: "dev", date: $d}, object: {sha: "cccccccccccccccccccccccccccccccccccccccc", type: "commit"}}]' >"$fix/src-tagobjs.json"
+    else
+      jq -n -c --arg t "$tag" --argjson i "$i" '{ref: ("refs/tags/" + $t), object: {sha: ("c" * 39 + ($i|tostring)|.[0:40]), type: "commit"}}' >>"$fix/tags.ndjson"
+    fi
+  done
+  # Shuffle the release order (newest in the middle) so sorting is the script's, not the fixture's.
+  jq -s '[.[12], .[3], .[0], .[11], .[7], .[1], .[10], .[2], .[9], .[4], .[8], .[5], .[6]]' "$fix/releases.ndjson" >"$fix/src-releases.json"
+  jq -s '.' "$fix/tags.ndjson" >"$fix/src-tags.json"
+}
+fresh_state() { # DIR — an empty mirror state
+  mkdir -p "$1"; printf '[]' >"$1/mirror-releases.json"; printf '[]' >"$1/mirror-tags.json"; printf '[]' >"$1/mirror-tagobjs.json"
+}
+
+FIX="$ROOT/fix"; build_fixtures "$FIX"
+FIX_CORRUPT="$ROOT/fix-corrupt"; build_fixtures "$FIX_CORRUPT" v0.1.11
+FIX_BADBODY="$ROOT/fix-badbody"; build_fixtures "$FIX_BADBODY" "" v0.1.5
+
+# run STATE_DIR FIX_DIR ARGS... — the script with the fake gh; OUTPUT, RC, GH_LOG set.
+run() {
+  local state="$1" fix="$2"; shift 2
+  GH_LOG="$ROOT/gh-$RANDOM$RANDOM.log"; : >"$GH_LOG"
+  OUTPUT="$(PATH="$SHIM:$PATH" GH_LOG="$GH_LOG" FAKE_GH_FIX="$fix" FAKE_GH_STATE="$state" PUBLISH_GUARD_GITLEAKS="$SHIM/gitleaks" \
+    BACKFILL_SCRIPTS_DIR="$SCRIPTS_DIR" SOURCE_REPO="${SOURCE_REPO-acme/src}" MIRROR_REPO="${MIRROR_REPO-mirror}" BINARY_KEEP="${BINARY_KEEP-10}" \
+    bash "$BACKFILL" "$@" 2>&1)"; RC=$?
+}
+has()    { [[ "$OUTPUT" == *"$1"* ]]; }
+hasg()   { [[ "$OUTPUT" == *$1* ]]; }   # $1 is a glob: `a*b`; escape [ ] as \[ \]
+writes() { grep -cE '^(api -X (POST|PATCH)|release upload) ' "$GH_LOG" || true; }
+posts()  { grep -c "^api -X POST repos/acme/mirror/$1 " "$GH_LOG" || true; }
+verdicts() { printf '%s\n' "$OUTPUT" | awk -v v="$1" '$NF == v && $1 ~ /^v[0-9]/ { n++ } END { print n + 0 }'; }
+
+echo "== backfill-releases.sh harness =="
+
+# ---- 1. dry-run (the default) writes nothing ---------------------------------------------
+S1="$ROOT/s1"; fresh_state "$S1"
+run "$S1" "$FIX"
+if [ "$RC" -eq 0 ] && [ "$(writes)" -eq 0 ] && [ "$(verdicts planned)" -eq 12 ] && has "12 release(s) match the filter, 12 in this run; binaries for the newest 10" && has "mode=dry-run" \
+   && has "v0.1.0 [stable] would: tag: create | release: create | text: 3 up/0 skip | binaries: none (older than the newest 10)" \
+   && has "v0.1.11 [stable] would: tag: create | release: create | text: 3 up/0 skip | binaries: 2 up/0 skip (+4/0 sig+cert)" \
+   && grep -q '^release download v0.1.11 --repo acme/src --dir .*/text --pattern' "$GH_LOG" && ! grep -q -- '--pattern tracebloc-' "$GH_LOG"; then
+  ok "dry-run (default): every read runs, text assets are fetched for the guard, no binary is fetched, zero writes, 12 rows planned"
+else bad "dry-run (rc=$RC writes=$(writes) planned=$(verdicts planned)): $OUTPUT"; fi
+if [ "$(jq length "$S1/mirror-releases.json")" -eq 0 ] && [ "$(jq length "$S1/mirror-tags.json")" -eq 0 ]; then ok "dry-run: the mirror state is untouched"; else bad "dry-run touched the mirror state"; fi
+
+# ---- 2. --apply makes exactly the expected writes -----------------------------------------
+S2="$ROOT/s2"; fresh_state "$S2"
+run "$S2" "$FIX" --apply
+first_rel="$(grep '^api -X POST repos/acme/mirror/releases ' "$GH_LOG" | head -1)"
+last_rel="$(grep '^api -X POST repos/acme/mirror/releases ' "$GH_LOG" | tail -1)"
+if [ "$RC" -eq 0 ] && [ "$(writes)" -eq 48 ] && [ "$(posts git/tags)" -eq 12 ] && [ "$(posts git/refs)" -eq 12 ] && [ "$(posts releases)" -eq 12 ] \
+   && [ "$(grep -c '^release upload ' "$GH_LOG")" -eq 12 ] && [ "$(verdicts "done")" -eq 12 ] && has "12 release(s) in this run — 12 written, 0 already complete, 0 refused"; then
+  ok "apply: 12 releases → exactly 48 writes (tag object, ref, release, one upload call each), all 12 done"
+else bad "apply (rc=$RC writes=$(writes) tags=$(posts git/tags) refs=$(posts git/refs) rel=$(posts releases)): $OUTPUT"; fi
+if [[ "$first_rel" == *"-f tag_name=v0.1.0 "*"-f make_latest=false"* ]] && [[ "$last_rel" == *"-f tag_name=v0.1.11 "*"-f make_latest=true"* ]] \
+   && [ "$(grep -c -- '-f make_latest=true' "$GH_LOG")" -eq 1 ] && ! grep -q 'v0.1.12-rc.1' "$GH_LOG"; then
+  ok "apply: oldest first, newest stable last and the only make_latest=true; the prerelease is never touched"
+else bad "apply order/latest: first='$first_rel' last='$last_rel'"; fi
+if [ "$(jq -r '.[] | .tag_name' "$S2/mirror-releases.json" | paste -sd' ' -)" = "${STABLE[*]}" ] && [ "$(jq -r '[.[] | select(.prerelease)] | length' "$S2/mirror-releases.json")" -eq 0 ] \
+   && [ "$(jq -r '.[] | select(.tag_name == "v0.1.11") | .assets | length' "$S2/mirror-releases.json")" -eq 9 ] && [ "$(jq -r '.[] | select(.tag_name == "v0.1.0") | .assets | length' "$S2/mirror-releases.json")" -eq 3 ]; then
+  ok "apply: the mirror holds the 12 stable releases; the newest carries 9 assets (3 text + 2 binaries + 4 sig/cert), the oldest 3"
+else bad "apply mirror state: $(jq -c '[.[] | {tag_name, n: (.assets|length)}]' "$S2/mirror-releases.json")"; fi
+tag0="$(jq -r '.[] | select(.tag == "v0.1.0")' "$S2/mirror-tagobjs.json")"; tag3="$(jq -r '.[] | select(.tag == "v0.1.3")' "$S2/mirror-tagobjs.json")"
+if [ "$(printf '%s' "$tag0" | jq -r .object.sha)" = "$HEAD_SHA" ] && [ "$(printf '%s' "$tag0" | jq -r .tagger.date)" = "2026-01-01T12:00:00Z" ] \
+   && [[ "$(printf '%s' "$tag0" | jq -r .message)" == *"Mirror release marker for v0.1.0"*"not at the sources"* ]] \
+   && [ "$(printf '%s' "$tag3" | jq -r .tagger.date)" = "$ANNOT_DATE" ] && [[ "$(printf '%s' "$tag3" | jq -r .message)" == *"--- original tag message ---"*"$ANNOT_MSG"* ]] \
+   && [ "$(jq -r '[.[] | .object.sha] | unique | length' "$S2/mirror-tagobjs.json")" -eq 1 ]; then
+  ok "tags: every mirror tag is an annotated marker on the mirror head; the date is the release's, or the source tag's own when annotated, with its message carried"
+else bad "tags: v0.1.0=$(printf '%s' "$tag0" | jq -c .) v0.1.3=$(printf '%s' "$tag3" | jq -c .)"; fi
+body0="$(jq -r '.[] | select(.tag_name == "v0.1.0") | .body' "$S2/mirror-releases.json")"; body11="$(jq -r '.[] | select(.tag_name == "v0.1.11") | .body' "$S2/mirror-releases.json")"
+if [[ "$body0" == "tracebloc CLI v0.1.0."*"verify it against SHA256SUMS"*"originally published 2026-01-01T12:00:00Z"*"re-run the installer"* ]] && [[ "$body0" != *"What's Changed"* ]] && [[ "$body0" != *"acme/src/pull/"* ]] \
+   && [[ "$body11" == "tracebloc CLI v0.1.11."*"originally published 2026-01-12T12:00:00Z"* ]] && [[ "$body11" != *"acme/src/pull/"* ]] && [[ "$body11" != *"re-run the installer"* ]] && has "notes=fixed"; then
+  ok "notes (default): the workflow's fixed text plus an original-date footer, no trace of the source body; the installer hint appears only where binaries are not carried"
+else bad "notes default: body0='$body0' body11='$body11'"; fi
+# The tag is created before its release (the fake refuses the other order) and the release before its uploads.
+if [ "$(grep -nE '^(api -X POST repos/acme/mirror/(git/tags|git/refs|releases)|release upload) ' "$GH_LOG" | head -4 | sed -E 's/^[0-9]+://; s/ .*//' | paste -sd' ' -)" = "api api api release" ]; then
+  ok "apply: per release the order is tag object, ref, release, upload"
+else bad "apply order: $(head -8 "$GH_LOG")"; fi
+# --notes source is the explicit opt-in that carries the source body.
+S2B="$ROOT/s2b"; fresh_state "$S2B"
+run "$S2B" "$FIX" --apply --notes source
+body0="$(jq -r '.[] | select(.tag_name == "v0.1.0") | .body' "$S2B/mirror-releases.json")"; body11="$(jq -r '.[] | select(.tag_name == "v0.1.11") | .body' "$S2B/mirror-releases.json")"
+if [ "$RC" -eq 0 ] && has "notes=source" && [[ "$body0" == "## What's Changed"*"acme/src/pull/1"*"originally published 2026-01-01T12:00:00Z"*"re-run the installer"* ]] && [[ "$body0" != *"tracebloc CLI v0.1.0."* ]] \
+   && [[ "$body11" == "## What's Changed"*"acme/src/pull/12"*"originally published 2026-01-12T12:00:00Z"* ]] && [[ "$body11" != *"re-run the installer"* ]]; then
+  ok "--notes source: the source body is carried with the same original-date footer, and only when asked for"
+else bad "notes source (rc=$RC): body0='$body0' body11='$body11'"; fi
+run "$S2B" "$FIX" --notes generated
+if [ "$RC" -eq 2 ] && has "--notes must be 'fixed' or 'source', not 'generated'" && [ "$(wc -l <"$GH_LOG" | tr -d ' ')" -eq 0 ]; then
+  ok "--notes with anything else is could-not-tell before any gh call"
+else bad "notes bogus (rc=$RC): $OUTPUT"; fi
+
+# ---- 3. a second --apply writes nothing ---------------------------------------------------
+before="$(cat "$S2/mirror-releases.json" "$S2/mirror-tags.json" | sha256_of /dev/stdin)"
+run "$S2" "$FIX" --apply
+after="$(cat "$S2/mirror-releases.json" "$S2/mirror-tags.json" | sha256_of /dev/stdin)"
+if [ "$RC" -eq 0 ] && [ "$(writes)" -eq 0 ] && [ "$(verdicts skipped)" -eq 12 ] && has "12 release(s) in this run — 0 written, 12 already complete, 0 refused" && [ "$before" = "$after" ] \
+   && ! grep -q '^release download .* --pattern install' "$GH_LOG"; then
+  ok "idempotent: a second --apply over the same mirror makes zero writes, downloads no asset it can compare by digest, and reports 12 already complete"
+else bad "idempotent (rc=$RC writes=$(writes) skipped=$(verdicts skipped)): $OUTPUT"; fi
+
+# ---- 4. BINARY_KEEP boundary ---------------------------------------------------------------
+# Newest 10 of the 12 stable: v0.1.2 (10th) carries binaries, v0.1.1 (11th) does not.
+if [ "$(jq -r '.[] | select(.tag_name == "v0.1.2") | .assets | length' "$S2/mirror-releases.json")" -eq 9 ] && [ "$(jq -r '.[] | select(.tag_name == "v0.1.1") | .assets | length' "$S2/mirror-releases.json")" -eq 3 ] \
+   && [ "$(jq -r '.[] | select(.tag_name == "v0.1.1") | [.assets[].name] | sort | join(" ")' "$S2/mirror-releases.json")" = "SHA256SUMS install.ps1 install.sh" ]; then
+  ok "BINARY_KEEP=10: the 10th newest (v0.1.2) carries binaries + sig/cert, the 11th (v0.1.1) carries exactly the three text assets"
+else bad "binary-keep boundary: v0.1.2=$(jq -c '.[] | select(.tag_name == "v0.1.2") | [.assets[].name]' "$S2/mirror-releases.json") v0.1.1=$(jq -c '.[] | select(.tag_name == "v0.1.1") | [.assets[].name]' "$S2/mirror-releases.json")"; fi
+S4="$ROOT/s4"; fresh_state "$S4"
+BINARY_KEEP=1 run "$S4" "$FIX"
+if [ "$RC" -eq 0 ] && hasg "v0.1.11 \[stable\] would: *binaries: 2 up/0 skip" && hasg "v0.1.10 \[stable\] would: *binaries: none (older than the newest 1)"; then
+  ok "BINARY_KEEP is read: with 1, only the newest release carries binaries"
+else bad "BINARY_KEEP=1 (rc=$RC): $OUTPUT"; fi
+BINARY_KEEP=ten run "$S4" "$FIX"
+if [ "$RC" -eq 2 ] && has "BINARY_KEEP 'ten' is not a non-negative integer"; then ok "BINARY_KEEP that is not a number is could-not-tell"; else bad "BINARY_KEEP=ten (rc=$RC): $OUTPUT"; fi
+
+# ---- 5. a binary that disagrees with SHA256SUMS is refused by name ---------------------------
+S5="$ROOT/s5"; fresh_state "$S5"
+run "$S5" "$FIX_CORRUPT" --apply
+if [ "$RC" -eq 1 ] && has "REFUSED v0.1.11 — binary 'tracebloc-v0.1.11-darwin-arm64' hashes to " && has "but the source release's SHA256SUMS says 0000000000000000000000000000000000000000000000000000000000000000 — not uploaded" \
+   && [ "$(verdicts refused)" -eq 1 ] && [ "$(verdicts "done")" -eq 11 ] && ! grep -q 'v0.1.11' <(grep -E '^(api -X POST|release upload) ' "$GH_LOG") && has "1 release(s) were refused" \
+   && [ "$(jq -r '[.[] | select(.tag_name == "v0.1.11")] | length' "$S5/mirror-releases.json")" -eq 0 ] && [ "$(jq length "$S5/mirror-releases.json")" -eq 11 ]; then
+  ok "sha mismatch: the binary is named, nothing of that release is written (no tag, no release, no upload), the other 11 go ahead, exit 1"
+else bad "sha mismatch (rc=$RC refused=$(verdicts refused) done=$(verdicts "done")): $OUTPUT"; fi
+# v0.1.11 is the newest stable, so its refusal is the "no latest" case: every
+# other release was created with make_latest=false. The newest stable the run
+# did write (v0.1.10) must be marked latest, by exactly one PATCH.
+if [ "$(grep -c '^api -X PATCH repos/acme/mirror/releases/' "$GH_LOG")" -eq 1 ] && has "latest: v0.1.11 was refused — marking v0.1.10, the newest stable release written in this run, as latest until v0.1.11 is re-run" \
+   && [ "$(jq -r '.[] | select(.tag_name == "v0.1.10") | .make_latest' "$S5/mirror-releases.json")" = true ] && [ "$(jq -r '[.[] | select(.make_latest == "true")] | length' "$S5/mirror-releases.json")" -eq 1 ]; then
+  ok "sha mismatch on the newest stable: the newest stable release the run did write is marked latest — releases/latest does not 404 behind a refusal"
+else bad "latest fallback (patches=$(grep -c '^api -X PATCH' "$GH_LOG" || true)): $(jq -c '[.[] | {tag_name, make_latest}]' "$S5/mirror-releases.json")"; fi
+run "$S5" "$FIX_CORRUPT"
+if [ "$RC" -eq 0 ] && [ "$(verdicts planned)" -eq 1 ] && [ "$(verdicts skipped)" -eq 11 ] && [ "$(writes)" -eq 0 ]; then
+  ok "sha mismatch: a dry-run afterwards plans only the refused release (binaries are checked at apply, not fetched for a plan)"
+else bad "sha mismatch dry-run after (rc=$RC planned=$(verdicts planned) skipped=$(verdicts skipped)): $OUTPUT"; fi
+
+# ---- 6. mirror unset / equal to the source: publish-mirror's rule ----------------------------
+S6="$ROOT/s6"; fresh_state "$S6"
+MIRROR_REPO='' run "$S6" "$FIX" --apply; a="$RC"; o1="$OUTPUT"; l1="$(wc -l <"$GH_LOG" | tr -d ' ')"
+MIRROR_REPO=src run "$S6" "$FIX" --apply; b="$RC"; o2="$OUTPUT"; l2="$(wc -l <"$GH_LOG" | tr -d ' ')"
+if [ "$a" -eq 1 ] && [[ "$o1" == *"publish-mirror: REFUSED — no mirror repository is configured (MIRROR_REPO is unset)"* ]] && [[ "$o1" == *"backfill-releases: REFUSED — the mirror target was refused above"* ]] \
+   && [ "$b" -eq 1 ] && [[ "$o2" == *"publish-mirror: REFUSED — mirror 'acme/src' is this repository"* ]] && [ "$l1" -eq 0 ] && [ "$l2" -eq 0 ]; then
+  ok "mirror unset, or equal to the source, is refused by publish-mirror's own rule before any gh call"
+else bad "mirror target (a=$a b=$b calls=$l1/$l2): $o1 / $o2"; fi
+MIRROR_REPO=SRC run "$S6" "$FIX"
+if [ "$RC" -eq 1 ] && has "is this repository"; then ok "mirror equal to the source is refused case-insensitively"; else bad "mirror case (rc=$RC): $OUTPUT"; fi
+
+# ---- 7. source derived from gh repo view when SOURCE_REPO is unset ----------------------------
+S7="$ROOT/s7"; fresh_state "$S7"
+SOURCE_REPO='' run "$S7" "$FIX"
+if [ "$RC" -eq 0 ] && has "source acme/src → mirror acme/mirror" && [ "$(head -1 "$GH_LOG")" = "repo view --json nameWithOwner" ]; then
+  ok "SOURCE_REPO unset: the source is what gh repo view reports, never a hardcoded name"
+else bad "source derivation (rc=$RC): $(head -2 "$GH_LOG") / $OUTPUT"; fi
+if ! grep -qE 'tracebloc/cli|"tracebloc"' "$REAL"; then ok "the script hardcodes no repository name"; else bad "a repository name is hardcoded in $REAL"; fi
+
+# ---- 8. prereleases: excluded by default, included on request, and they move the boundary -----
+S8="$ROOT/s8"; fresh_state "$S8"
+run "$S8" "$FIX" --include-prerelease
+if [ "$RC" -eq 0 ] && [ "$(verdicts planned)" -eq 13 ] && has "13 release(s) match the filter" && has "v0.1.12-rc.1 [prerelease] would: tag: create | release: create | text: 3 up/0 skip | binaries: 2 up/0 skip" \
+   && hasg "v0.1.2 \[stable\] would: *binaries: none (older than the newest 10)" && hasg "v0.1.3 \[stable\] would: *binaries: 2 up/0 skip"; then
+  ok "--include-prerelease: the rc is planned, and being the newest it takes a binary slot — v0.1.2 drops out of the newest 10"
+else bad "include-prerelease (rc=$RC planned=$(verdicts planned)): $OUTPUT"; fi
+run "$S8" "$FIX" --include-prerelease --apply
+if [ "$RC" -eq 0 ] && [ "$(grep -c -- '-F prerelease=true' "$GH_LOG")" -eq 1 ] && [ "$(grep -c -- '-f tag_name=v0.1.12-rc.1 ' "$GH_LOG")" -eq 1 ] \
+   && [[ "$(grep -- '-f tag_name=v0.1.12-rc.1 ' "$GH_LOG")" == *"-f make_latest=false"* ]] && [[ "$(grep -- '-f tag_name=v0.1.11 ' "$GH_LOG")" == *"-f make_latest=true"* ]]; then
+  ok "--include-prerelease --apply: the rc is created as a prerelease and is never make_latest; the newest STABLE is"
+else bad "include-prerelease apply (rc=$RC): $(grep -- 'tag_name=v0.1.1' "$GH_LOG")"; fi
+
+# ---- 9. a read that fails is could-not-tell, naming the call ----------------------------------
+S9="$ROOT/s9"; fresh_state "$S9"
+FAKE_GH_FAIL_RE='^api --paginate repos/acme/src/releases$' run "$S9" "$FIX" --apply; a="$RC"; o1="$OUTPUT"; w1="$(writes)"
+FAKE_GH_FAIL_RE='^api --paginate repos/acme/mirror/releases$' run "$S9" "$FIX" --apply; b="$RC"; o2="$OUTPUT"; w2="$(writes)"
+FAKE_GH_FAIL_RE='^release download v0.1.4 ' run "$S9" "$FIX" --apply; c="$RC"; o3="$OUTPUT"
+if [ "$a" -eq 2 ] && [[ "$o1" == *"COULD NOT TELL — gh api --paginate repos/acme/src/releases failed: gh: Internal Server Error (HTTP 500)"* ]] && [ "$w1" -eq 0 ] \
+   && [ "$b" -eq 2 ] && [[ "$o2" == *"COULD NOT TELL — gh api --paginate repos/acme/mirror/releases failed"* ]] && [ "$w2" -eq 0 ] \
+   && [ "$c" -eq 2 ] && [[ "$o3" == *"COULD NOT TELL — gh release download v0.1.4 --repo acme/src"*"failed"* ]]; then
+  ok "a failing read (source list, mirror list, an asset download) is exit 2 naming the call — never 'no releases', never a write"
+else bad "api failure (a=$a b=$b c=$c w=$w1/$w2): $o1 / $o2 / $o3"; fi
+if [ "$(jq -r '[.[] | select(.tag_name | test("^v0.1.[0-3]$"))] | length' "$S9/mirror-releases.json")" -eq 4 ]; then
+  S9B="$ROOT/s9b"; fresh_state "$S9B"
+  FAKE_GH_FAIL_RE='^release download v0.1.4 ' run "$S9B" "$FIX" --apply
+  run "$S9B" "$FIX" --apply
+  if [ "$RC" -eq 0 ] && [ "$(verdicts skipped)" -eq 4 ] && [ "$(verdicts "done")" -eq 8 ] && [ "$(jq length "$S9B/mirror-releases.json")" -eq 12 ]; then
+    ok "resume after a mid-run failure: the completed releases are skipped, the rest are written, the mirror ends complete"
+  else bad "resume (rc=$RC skipped=$(verdicts skipped) done=$(verdicts "done")): $OUTPUT"; fi
+else bad "mid-run failure did not stop at v0.1.4: $(jq -c '[.[].tag_name]' "$S9/mirror-releases.json")"; fi
+FAKE_GH_EMPTY_MIRROR=1 run "$S9" "$FIX" --apply
+if [ "$RC" -eq 1 ] && has "REFUSED — mirror 'acme/mirror' has no commit on 'main' to anchor tags to; publish the README first" && [ "$(writes)" -eq 0 ]; then
+  ok "an empty mirror is refused with instructions — tags are never anchored to an invented commit"
+else bad "empty mirror (rc=$RC): $OUTPUT"; fi
+# A read that returns garbage is parsed inside "$(jq_of …)" — a subshell. The
+# reason must still reach the operator (die2 writes it to stderr; on stdout it
+# would be captured into the variable and lost) and the run must still end 2.
+S9C="$ROOT/s9c"; fresh_state "$S9C"
+FAKE_GH_GARBLE_RE='^api repos/acme/mirror$' run "$S9C" "$FIX" --apply
+if [ "$RC" -eq 2 ] && [[ "$OUTPUT" == *"COULD NOT TELL — could not parse "*"/mirror.json with '.full_name':"* ]] && [ "$(writes)" -eq 0 ]; then
+  ok "a read that returns non-JSON, parsed inside a \$(...) substitution, is exit 2 naming the file and filter — the reason reaches the operator, not the variable"
+else bad "garbled read in a substitution (rc=$RC writes=$(writes)): $OUTPUT"; fi
+
+# ---- 10. under --notes source a refuse-tier needle in a body refuses that release, naming the tier;
+#          the default never reads the body into the notes, so the same release goes through -------
+S10="$ROOT/s10"; fresh_state "$S10"
+run "$S10" "$FIX_BADBODY" --apply --notes source
+if [ "$RC" -eq 1 ] && has "REFUSED v0.1.5 — the guard refused the notes or a text asset: [forbidden-strings] REFUSED — [strings-refuse] needle 'arn:aws:' found in 1 staged line(s)" \
+   && has "assets/RELEASE_NOTES.md:" && ! has "role/planted" && [ "$(verdicts refused)" -eq 1 ] && [ "$(verdicts "done")" -eq 11 ] \
+   && ! grep -q 'v0.1.5' <(grep -E '^(api -X POST|release upload) ' "$GH_LOG") && [ "$(jq -r '[.[] | select(.tag_name == "v0.1.5")] | length' "$S10/mirror-releases.json")" -eq 0 ]; then
+  ok "--notes source, forbidden string in a body: the release is refused naming the tier and the notes file, the text is not echoed, nothing of it is written, exit 1"
+else bad "bad body under --notes source (rc=$RC refused=$(verdicts refused)): $OUTPUT"; fi
+run "$S10" "$FIX_BADBODY" --apply
+if [ "$RC" -eq 0 ] && [ "$(verdicts "done")" -eq 1 ] && [[ "$(jq -r '.[] | select(.tag_name == "v0.1.5") | .body' "$S10/mirror-releases.json")" == "tracebloc CLI v0.1.5."*"originally published 2026-01-06T12:00:00Z"* ]] \
+   && ! grep -q 'role/planted' "$S10/mirror-releases.json"; then
+  ok "default notes: the same release goes through with the workflow's fixed text plus the date footer; the planted body never reaches the mirror"
+else bad "bad body under the default notes (rc=$RC): $OUTPUT"; fi
+S10B="$ROOT/s10b"; fresh_state "$S10B"
+run "$S10B" "$FIX_BADBODY" --apply
+if [ "$RC" -eq 0 ] && [ "$(verdicts "done")" -eq 12 ] && [ "$(jq length "$S10B/mirror-releases.json")" -eq 12 ] && ! grep -q 'role/planted' "$S10B/mirror-releases.json"; then
+  ok "default notes on a fresh mirror: all 12 written, none refused — a bad source body is not a reason to hold up a release the mirror never quotes"
+else bad "fresh mirror, default notes (rc=$RC done=$(verdicts "done")): $OUTPUT"; fi
+
+# ---- 11. present-but-different is refused, never replaced; a dangling mirror tag is refused ------
+S11="$ROOT/s11"; fresh_state "$S11"
+jq -n '[{id: 1, tag_name: "v0.1.9", name: "v0.1.9", body: "x", prerelease: false, draft: false, assets: [{name: "install.sh", digest: "sha256:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef"}]}]' >"$S11/mirror-releases.json"
+jq -n --arg h "$HEAD_SHA" '[{ref: "refs/tags/v0.1.9", object: {sha: $h, type: "commit"}}, {ref: "refs/tags/v0.1.7", object: {sha: "9999999999999999999999999999999999999999", type: "commit"}}]' >"$S11/mirror-tags.json"
+run "$S11" "$FIX" --apply
+if [ "$RC" -eq 1 ] && has "REFUSED v0.1.9 — asset 'install.sh' is on the mirror with SHA256 deadbeef" && has "a published asset is never replaced" \
+   && has "REFUSED v0.1.7 — tag 'v0.1.7' exists on the mirror but points at commit 9999999999999999999999999999999999999999, which the mirror does not have — a dangling tag is not repointed" \
+   && [ "$(verdicts refused)" -eq 2 ] && [ "$(verdicts "done")" -eq 10 ] && ! grep -qE 'v0.1.(7|9)' <(grep -E '^(api -X POST|release upload) ' "$GH_LOG"); then
+  ok "present-with-a-different-digest and a dangling mirror tag are each refused by name, nothing of those releases is written, the other 10 go ahead"
+else bad "present/dangling (rc=$RC refused=$(verdicts refused) done=$(verdicts "done")): $OUTPUT"; fi
+
+# ---- 12. --only-tag / --from-tag narrow the run, not the binary decision --------------------------
+S12="$ROOT/s12"; fresh_state "$S12"
+run "$S12" "$FIX" --apply --only-tag v0.1.1
+if [ "$RC" -eq 0 ] && [ "$(writes)" -eq 4 ] && has "12 release(s) match the filter, 1 in this run" && [ "$(jq -r '.[0].assets | length' "$S12/mirror-releases.json")" -eq 3 ]; then
+  ok "--only-tag: one release, 4 writes; v0.1.1 stays outside the newest 10 even when it is the only release in the run"
+else bad "only-tag (rc=$RC writes=$(writes)): $OUTPUT"; fi
+run "$S12" "$FIX" --apply --from-tag v0.1.10
+if [ "$RC" -eq 0 ] && [ "$(writes)" -eq 8 ] && has "2 in this run" && [ "$(jq -r '[.[].tag_name] | join(" ")' "$S12/mirror-releases.json")" = "v0.1.1 v0.1.10 v0.1.11" ]; then
+  ok "--from-tag: that release and every newer one, oldest first"
+else bad "from-tag (rc=$RC writes=$(writes)): $OUTPUT / $(jq -c '[.[].tag_name]' "$S12/mirror-releases.json")"; fi
+run "$S12" "$FIX" --only-tag v9.9.9; a="$RC"; o1="$OUTPUT"
+run "$S12" "$FIX" --only-tag v0.1.12-rc.1; b="$RC"; o2="$OUTPUT"
+run "$S12" "$FIX" --from-tag v0.1.1 --only-tag v0.1.2; c="$RC"; o3="$OUTPUT"
+if [ "$a" -eq 2 ] && [[ "$o1" == *"--only-tag 'v9.9.9' is not a release of 'acme/src' matching the filter"* ]] && [ "$b" -eq 2 ] && [[ "$o2" == *"is not a release of 'acme/src' matching the filter"* ]] \
+   && [ "$c" -eq 2 ] && [[ "$o3" == *"--from-tag and --only-tag exclude each other"* ]]; then
+  ok "an unknown tag, a filtered-out prerelease, or both flags at once are could-not-tell"
+else bad "tag flags (a=$a b=$b c=$c): $o1 / $o2 / $o3"; fi
+
+# ---- 13. the guard is really consulted: a strict run refuses the report tier ----------------------
+S13="$ROOT/s13"; fresh_state "$S13"
+FIX_REPORT="$ROOT/fix-report"; build_fixtures "$FIX_REPORT"
+jq '(.[] | select(.tag_name == "v0.1.6") | .body) |= . + "\n* tested against https://dev-api.tracebloc.io"' "$FIX_REPORT/src-releases.json" >"$FIX_REPORT/t.json" && mv "$FIX_REPORT/t.json" "$FIX_REPORT/src-releases.json"
+run "$S13" "$FIX_REPORT" --notes source; a="$RC"; o1="$OUTPUT"
+run "$S13" "$FIX_REPORT" --notes source --strict; b="$RC"; o2="$OUTPUT"
+run "$S13" "$FIX_REPORT" --strict; c="$RC"; o3="$OUTPUT"
+if [ "$a" -eq 0 ] && [ "$b" -eq 1 ] && [[ "$o2" == *"REFUSED v0.1.6 — the guard refused"*"[strings-report (strict)] needle 'dev-api\.tracebloc\.io' found in 1 staged line(s)"* ]]; then
+  ok "--strict is passed to the guard: under --notes source a report-tier needle in a body is counted, and refuses under --strict, tier named"
+else bad "strict (a=$a b=$b): $o1 / $o2"; fi
+if [ "$c" -eq 0 ] && [ "$(printf '%s\n' "$o3" | awk '$NF == "planned" && $1 ~ /^v[0-9]/ { n++ } END { print n + 0 }')" -eq 12 ] && [[ "$o3" != *"dev-api"* ]]; then
+  ok "--strict with the default notes: the report-tier body is never staged, so all 12 are planned — the reason fixed notes are the default"
+else bad "strict default notes (c=$c): $o3"; fi
+
+# ---- 14. the guard's scratch commit is unsigned even under a global commit.gpgsign -----------
+# The script runs on a human's machine. A global gpgsign that cannot sign as
+# backfill@localhost must not end the run before a release is planned.
+S14="$ROOT/s14"; fresh_state "$S14"
+cat >"$ROOT/gpg-fail" <<'EOF'
+#!/usr/bin/env bash
+echo "gpg: signing failed: No secret key" >&2; exit 2
+EOF
+chmod +x "$ROOT/gpg-fail"
+printf '[commit]\n\tgpgsign = true\n[gpg]\n\tprogram = %s\n' "$ROOT/gpg-fail" >"$ROOT/gitconfig-gpgsign"
+GIT_CONFIG_GLOBAL="$ROOT/gitconfig-gpgsign" run "$S14" "$FIX"
+if [ "$RC" -eq 0 ] && [ "$(verdicts planned)" -eq 12 ] && ! has "could not commit the guard's scratch checkout"; then
+  ok "a global commit.gpgsign=true with a failing signer does not end the run — the guard's scratch commit is made unsigned, all 12 planned"
+else bad "gpgsign (rc=$RC planned=$(verdicts planned)): $OUTPUT"; fi
+
+echo
+printf 'backfill-releases-verify: %d passed, %d failed\n' "$PASS" "$FAIL"
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 30 ]