diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d52d01c..1bf1dd1c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -93,6 +93,18 @@ 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 + 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 @@ -127,6 +139,35 @@ 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 + # 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/.github/workflows/mirror-publish.yml b/.github/workflows/mirror-publish.yml new file mode 100644 index 00000000..66f56d76 --- /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 00000000..41f4d35c --- /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 00000000..e88a5f87 --- /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/VERSION b/VERSION index dbca4f35..61012ac6 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 e295189e..88a241c6 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 2a1f107a..2d3a104e 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 8adee687..9f6c068c 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: $CLIENT_ENV, then prod)") + "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} @@ -65,8 +75,22 @@ 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)} } + // 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 @@ -461,7 +682,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 } @@ -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 f4b9f31b..f2cffd70 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 d69dbf55..63cb335d 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/env_resolution_test.go b/internal/cli/env_resolution_test.go index 8449ec53..b049c473 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 00000000..7274f960 --- /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 05056775..9194fd02 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 d08e9359..1b152ec2 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. @@ -22,7 +28,8 @@ 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) + --force start a new device flow even when this machine already holds a valid session -h, --help help for login Global Flags: @@ -50,7 +57,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 af957237..1559a4cb 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" @@ -809,7 +816,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 e2670cbe..6ed3a4fe 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 ec9de8bb..490e13cb 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", diff --git a/scripts/RELEASE_CHECKLIST.md b/scripts/RELEASE_CHECKLIST.md index e42b6cac..fdec18bc 100644 --- a/scripts/RELEASE_CHECKLIST.md +++ b/scripts/RELEASE_CHECKLIST.md @@ -28,6 +28,64 @@ 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. + +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 00000000..46026ff2 --- /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/publish-guard.sh b/scripts/publish-guard.sh new file mode 100755 index 00000000..ef1aae27 --- /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 00000000..c15d6be9 --- /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/backfill-releases-verify.sh b/scripts/tests/backfill-releases-verify.sh
new file mode 100644
index 00000000..2b573e4b
--- /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 ]
diff --git a/scripts/tests/mirror-publish-workflow-verify.sh b/scripts/tests/mirror-publish-workflow-verify.sh
new file mode 100644
index 00000000..63f6741b
--- /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 00000000..7ea5483c
--- /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 00000000..50427087
--- /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 ]