From efb634c165f33c3dce3a6f7a89145db10b826faa Mon Sep 17 00:00:00 2001 From: Lukas Wuttke Date: Thu, 10 Sep 2026 15:54:40 +0200 Subject: [PATCH 1/7] ci(mirror): fail-closed publish guard + publisher for the public deliverable mirror The public face of this repo becomes a separate, deliverable-only mirror: README/LICENSE/docs on its branch and each release with its signed assets. No Go source ever leaves. Nothing reaches the mirror except through the new Mirror publish workflow, and nothing leaves that workflow except what scripts/publish-guard.sh staged from the explicit allowlist (.publish-include) and cleared through four guards: forbidden paths and forbidden strings (.publish-forbidden), and gitleaks. Every guard fails closed; "could not tell" never publishes. scripts/publish-mirror.sh does the push: it refuses an unset mirror and a mirror equal to this repository, pushes plainly (never force), and never overwrites an existing release. The workflow runs after Release completes and on dispatch with dry-run defaulting to true; the mirror name (MIRROR_REPO) has no default. Customer identifiers for the string scan are supplied privately at publish time, not committed to this public file. build.yml's Installer job shellchecks the scripts and runs both harnesses. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/build.yml | 16 ++ .github/workflows/mirror-publish.yml | 289 ++++++++++++++++++++ .publish-forbidden | 67 +++++ .publish-include | 16 ++ scripts/RELEASE_CHECKLIST.md | 10 + scripts/publish-guard.sh | 356 +++++++++++++++++++++++++ scripts/publish-mirror.sh | 173 ++++++++++++ scripts/tests/publish-guard-verify.sh | 243 +++++++++++++++++ scripts/tests/publish-mirror-verify.sh | 128 +++++++++ 9 files changed, 1298 insertions(+) create mode 100644 .github/workflows/mirror-publish.yml create mode 100644 .publish-forbidden create mode 100644 .publish-include create mode 100755 scripts/publish-guard.sh create mode 100755 scripts/publish-mirror.sh create mode 100755 scripts/tests/publish-guard-verify.sh create mode 100755 scripts/tests/publish-mirror-verify.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9d52d01..988878f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -93,6 +93,12 @@ 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 + bash -n scripts/publish-guard.sh + bash -n scripts/publish-mirror.sh # format.sh's own fail-closed properties. Formatters are stubbed, so this is # hermetic and needs no Go toolchain — which is why it lives in this job # rather than Lint. It exists because the first cut of format.sh reported @@ -127,6 +133,16 @@ 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 test: timeout-minutes: 15 diff --git a/.github/workflows/mirror-publish.yml b/.github/workflows/mirror-publish.yml new file mode 100644 index 0000000..68a4f85 --- /dev/null +++ b/.github/workflows/mirror-publish.yml @@ -0,0 +1,289 @@ +# 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). +# 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. +# +# 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: "" + +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 }} + RUN_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + VAR_MIRROR: ${{ vars.MIRROR_REPO }} + steps: + - 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 + PRERELEASE=false + 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. + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease >"$RUNNER_TEMP/release.json"; then + echo "::error::no release '$TAG' on $GITHUB_REPOSITORY — nothing to mirror." + 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")" + REF="$TAG" + 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 + REF="$GITHUB_SHA" + fi + { + echo "tag=$TAG" + echo "dry_run=$DRY_RUN" + echo "ref=$REF" + echo "prerelease=$PRERELEASE" + } >>"$GITHUB_OUTPUT" + echo "plan: event=$EVENT_NAME tag='${TAG:-}' ref=$REF dry_run=$DRY_RUN prerelease=$PRERELEASE" + + - name: Check out the source at the release tag + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ steps.plan.outputs.ref }} + + - 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 --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 + env: + TAG: ${{ steps.plan.outputs.tag }} + run: | + set -uo pipefail + args=(--source . --out "$RUNNER_TEMP/stage" --extra-forbidden "$RUNNER_TEMP/tenants.txt") + [ -z "$TAG" ] || args+=(--assets "$RUNNER_TEMP/assets") + bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-tree.log" + rc=${PIPESTATUS[0]} + { + 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 + if: steps.plan.outputs.dry_run != 'true' + id: target + run: | + set -euo pipefail + REPO="$(bash scripts/publish-mirror.sh target --mirror "${INPUT_MIRROR:-$VAR_MIRROR}" --source-repo "$GITHUB_REPOSITORY")" + echo "repo=$REPO" >>"$GITHUB_OUTPUT" + echo "name=${REPO#*/}" >>"$GITHUB_OUTPUT" + echo "mirror: $REPO" + + - 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 + if: steps.plan.outputs.dry_run != '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' + OUT="$(bash scripts/publish-mirror.sh tree --stage "$RUNNER_TEMP/stage/tree" --repo "$REPO" --branch "$BRANCH" --message "Publish $TAG")" + echo "$OUT" + echo "sha=${OUT#* }" >>"$GITHUB_OUTPUT" + + - 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 }} + PRERELEASE: ${{ steps.plan.outputs.prerelease }} + run: | + set -euo pipefail + # Fixed notes, not the source release's generated ones: those list + # merged pull requests by title, which is development history, not + # the deliverable. + { + echo "tracebloc CLI $TAG." + echo + echo "Install with the one-liner in the README, or download a binary below and" + echo "verify it against SHA256SUMS and its cosign .sig/.cert (recipe in the README)." + } >"$RUNNER_TEMP/notes.md" + args=(release --tag "$TAG" --repo "$REPO" --target "$SHA" --assets "$RUNNER_TEMP/stage/assets" --notes "$RUNNER_TEMP/notes.md") + [ "$PRERELEASE" != "true" ] || args+=(--prerelease) + bash scripts/publish-mirror.sh "${args[@]}" diff --git a/.publish-forbidden b/.publish-forbidden new file mode 100644 index 0000000..7d4b7df --- /dev/null +++ b/.publish-forbidden @@ -0,0 +1,67 @@ +# .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. Three sections; `#` starts a comment. +# +# [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] 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). +# [allow] exact tokens removed from a line before a [strings] 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] +# Internal tracker and RFC identifiers — a reader of the mirror cannot open them. +backend# +rfcs# +RFC-0 +RFC-BACKEND +e2e-test-agent# +tracebloc/backend +# Non-production tracebloc hosts. +dev-api\.tracebloc\.io +stg-api\.tracebloc\.io +dev\.tracebloc\.io +stg\.tracebloc\.io +# 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. The guard +# refuses to run the scan when that list is missing or empty. + +[allow] +support@tracebloc\.io diff --git a/.publish-include b/.publish-include new file mode 100644 index 0000000..e88a5f8 --- /dev/null +++ b/.publish-include @@ -0,0 +1,16 @@ +# .publish-include — what the public mirror of this repo MAY carry. +# +# Read by scripts/publish-guard.sh. One glob per line; `#` starts a comment. +# `*` and `?` do not cross `/`, `**` does; a leading `!` takes matching files +# back out. Only tracked files are considered. Anything not matched here is +# excluded by construction — .publish-forbidden is the second lock. +# +# The mirror is README + releases. The binaries, SHA256SUMS, signatures and +# the two installers travel as RELEASE ASSETS (copied from this repo's release +# by the publish workflow and scanned by the same guard), never as tree files. +# No Go source, no Makefile, no workflows: the forbidden list refuses each of +# those by name should a line here ever widen. +README.md +LICENSE +# The user docs README links to. One level only: docs/rfcs/ stays home. +docs/*.md diff --git a/scripts/RELEASE_CHECKLIST.md b/scripts/RELEASE_CHECKLIST.md index e42b6ca..78f8ddc 100644 --- a/scripts/RELEASE_CHECKLIST.md +++ b/scripts/RELEASE_CHECKLIST.md @@ -28,6 +28,16 @@ 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. + GitHub Releases plus the cosign-verified `install.sh` are the install path — a Homebrew tap and the `install.tracebloc.io` vanity URL were considered and dropped diff --git a/scripts/publish-guard.sh b/scripts/publish-guard.sh new file mode 100755 index 0000000..702f730 --- /dev/null +++ b/scripts/publish-guard.sh @@ -0,0 +1,356 @@ +#!/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 `[strings]`: needles (extended +# regex, case-insensitive) that must not appear in +# any staged text file. `[allow]` entries are exact +# tokens spared before a needle is re-tested (a +# public support mailbox beside a rule that bans +# every other mailbox). More needles can be handed +# in privately with --extra-forbidden. +# 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, +# 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 [strings] 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 +# +# 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="" +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 ;; + -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 ------------------------ +# read_list FILE SECTION — print the entries of SECTION ([paths] / [strings] / +# [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" ' + /^[[:space:]]*(#|$)/ { next } + /^\[[A-Za-z-]+\][[:space:]]*$/ { sec = $0; sub(/^\[/, "", sec); sub(/\].*$/, "", sec); next } + { line = $0; sub(/[[:space:]]+$/, "", line) + if (want == "" || sec == want) print line } + ' "$1" +} + +# 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 + 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 -------------------------------------------------- +# 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_needles n_allow=0 total=0 extra + if [ ! -r "$FORBIDDEN" ]; then cant_tell "$g" "forbidden list '$FORBIDDEN' is missing or unreadable"; RAN=$((RAN + 1)); return; fi + read_list "$FORBIDDEN" strings >"$TMP/needles.txt" + read_list "$FORBIDDEN" allow >"$TMP/allow.txt" + 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.txt" + done + n_needles="$(grep -c . "$TMP/needles.txt" || true)" + n_allow="$(grep -c . "$TMP/allow.txt" || true)" + if [ "$n_needles" -eq 0 ]; then cant_tell "$g" "'$FORBIDDEN' has no [strings] entries — a scan with no needles proves nothing"; RAN=$((RAN + 1)); return; fi + + # 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 shown allow_expr + allow_expr="$(paste -sd'|' "$TMP/allow.txt")" + while IFS= read -r needle; do + # 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 cant_tell "$g" "grep exited $rc on needle '$needle': $(tr '\n' ' ' <"$TMP/grep.err")"; RAN=$((RAN + 1)); return; fi + [ "$rc" -eq 0 ] || continue + # [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. + # 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#$allow_expr# #g" "$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 cant_tell "$g" "re-test after [allow] stripping exited $rc on needle '$needle'"; RAN=$((RAN + 1)); return; fi + awk 'NR == FNR { keep[$1] = 1; next } (FNR in keep)' "$TMP/kept.txt" "$TMP/locs.txt" >"$TMP/hits.txt" + hits="$(grep -c . "$TMP/hits.txt" || true)" + [ "$hits" -gt 0 ] || continue + total=$((total + hits)) + refuse "$g" "needle '$needle' found in $hits staged line(s):" + { echo "needle '$needle':"; sed "s|^$OUT/||" "$TMP/hits.txt"; } >>"$REPORT" + shown="$(sed "s|^$OUT/||" "$TMP/hits.txt" | head -20 | sed 's/^/ /')" + printf '%s\n' "$shown" + [ "$hits" -le 20 ] || echo " … and $((hits - 20)) more (full list in publish-guard-report.txt)" + done <"$TMP/needles.txt" + if [ "$total" -eq 0 ]; then + note "$g" "clean ($n_needles needle(s), $n_allow allow token(s); $n_text text file(s) scanned, $n_bin binary file(s) opaque to this scan)" + else + note "$g" "$total hit(s) across $n_needles needle(s); $n_text text file(s) scanned, $n_bin binary file(s) opaque to this scan" + fi + RAN=$((RAN + 1)) +} + +# ---- guard 4: gitleaks ----------------------------------------------------------- +guard_gitleaks() { + local g="gitleaks" bin="${PUBLISH_GUARD_GITLEAKS:-gitleaks}" rc + if ! command -v "$bin" >/dev/null 2>&1; then cant_tell "$g" "scanner '$bin' is not on PATH — a scan that did not run is not a clean scan"; RAN=$((RAN + 1)); return; fi + # Leaks exit with a code no crash uses (default 1 is also "something broke"), + # so a scanner failure cannot be misread as either verdict. + "$bin" detect --no-git --redact --no-banner --exit-code 9 --source "$OUT" >"$TMP/gitleaks.out" 2>&1; rc=$? + case "$rc" in + 0) note "$g" "clean ($("$bin" version 2>/dev/null | head -1 || echo 'version unknown'), default rules, $(staged_paths | grep -c .) staged file(s))" ;; + 9) refuse "$g" "secrets detected in the staged tree:"; grep -vE '^[0-9]+:[0-9]+[AP]M' "$TMP/gitleaks.out" | sed 's/^/ /' | tee -a "$REPORT" ;; + *) cant_tell "$g" "scanner exited $rc: $(tail -3 "$TMP/gitleaks.out" | tr '\n' ' ')" ;; + esac + RAN=$((RAN + 1)) +} + +# ---- run everything, then judge --------------------------------------------------- +guard_allowlist +stage_assets +guard_forbidden_paths +guard_forbidden_strings +guard_gitleaks + +cp "$REPORT" "$OUT/publish-guard-report.txt" 2>/dev/null || true + +if [ "$RAN" -ne "$GUARDS_EXPECTED" ]; then + cant_tell "self-check" "$RAN of $GUARDS_EXPECTED guards reached a verdict" +fi +case "$WORST" in + 0) echo "publish-guard: OK — all $GUARDS_EXPECTED guards ran and passed; $OUT/tree is the deliverable." ;; + 1) echo "::error::publish-guard: REFUSED — do not publish $OUT (see the [guard] lines above)." ;; + *) echo "::error::publish-guard: COULD NOT TELL — do not publish $OUT (see the [guard] lines above)." ;; +esac +exit "$WORST" diff --git a/scripts/publish-mirror.sh b/scripts/publish-mirror.sh new file mode 100755 index 0000000..18f5e21 --- /dev/null +++ b/scripts/publish-mirror.sh @@ -0,0 +1,173 @@ +#!/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] +# 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. +# +# tree --stage DIR --repo OWNER/NAME --branch NAME --message TEXT +# [--remote URL] +# 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 `. +# +# 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_.-]+$' + +cmd_target() { + local mirror="" source_repo="" owner="" + while [ "$#" -gt 0 ]; do + case "$1" in + --mirror) mirror="${2:-}"; shift 2 ;; + --source-repo) source_repo="${2:-}"; shift 2 ;; + --owner) owner="${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 + printf '%s\n' "$full" +} + +cmd_tree() { + local stage="" repo="" branch="" message="" remote="" + 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 ;; + *) 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" + if [ "$existed" -eq 1 ] && git -C "$work" diff --cached --quiet; then + echo "unchanged $(git -C "$work" rev-parse HEAD)" + 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")" + echo "pushed $(git -C "$work" rev-parse HEAD)" +} + +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/publish-guard-verify.sh b/scripts/tests/publish-guard-verify.sh
new file mode 100755
index 0000000..17e0da3
--- /dev/null
+++ b/scripts/tests/publish-guard-verify.sh
@@ -0,0 +1,243 @@
+#!/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]\n'; printf '%s\n' 'backend#' 'RFC-0' 'dev-api\.tracebloc\.io' '[A-Za-z0-9._%+-]+@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 (4 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]\nbackend#\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]\nbackend#\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 ---------------------------------------------------------
+fresh ref; plant README.md 'see backend#1234' && guard
+if [ "$RC" -eq 1 ] && has "[forbidden-strings] REFUSED — needle 'backend#' found in 1 staged line(s):" && has "    tree/README.md:2" && ! has "see backend#1234"; then
+  ok "mutation: an internal tracker reference is refused; file:line named, text not echoed"
+else bad "tracker ref (rc=$RC): $OUTPUT"; fi
+
+fresh host; plant docs/usage.md 'API dev-api.tracebloc.io' && guard
+if [ "$RC" -eq 1 ] && has "needle 'dev-api\.tracebloc\.io' found in 1 staged line(s):" && has "tree/docs/usage.md:2"; then ok "mutation: a non-production hostname is refused"; else bad "hostname (rc=$RC): $OUTPUT"; fi
+
+fresh case; plant README.md 'BACKEND#7' && guard
+if [ "$RC" -eq 1 ] && has "needle 'backend#' 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 "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
+
+fresh extra; printf 'planted-tenant\n' >"$ROOT/extra/tenants.txt"; plant docs/usage.md 'for Planted-Tenant' && guard --extra-forbidden "$ROOT/extra/tenants.txt"
+if [ "$RC" -eq 1 ] && has "needle 'planted-tenant' found in 1 staged line(s):" && has "tree/docs/usage.md:2" && has "across 5 needle(s)"; then
+  ok "mutation: a private needle from --extra-forbidden is enforced"
+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# backend#42\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 "needle 'backend#' found in 1 staged line(s):" && has "assets/install.sh:2"; then
+  ok "an internal reference 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\000backend#1\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
+
+fresh nostrings; printf '[paths]\ntests/\n' >"$SRC/.publish-forbidden"; guard
+if [ "$RC" -eq 2 ] && has "[forbidden-strings] COULD NOT TELL" && has "has no [strings] entries"; then ok "no [strings] entries is could-not-tell"; else bad "no strings (rc=$RC): $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" -le 1 ] && ! has "COULD NOT TELL" && has "[forbidden-paths] clean" && [ -z "$missing" ] && [ -z "$present" ]; then
+  ok "the committed .publish-include stages README/LICENSE/docs of the real repo and no source"
+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 30 ]
diff --git a/scripts/tests/publish-mirror-verify.sh b/scripts/tests/publish-mirror-verify.sh
new file mode 100755
index 0000000..1eb1e01
--- /dev/null
+++ b/scripts/tests/publish-mirror-verify.sh
@@ -0,0 +1,128 @@
+#!/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
+
+# ---- 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
+
+# ---- 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 14 ]

From b54b5b5e06bf340ce97500859bdfc293b97a2681 Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 16:18:37 +0200
Subject: [PATCH 2/7] ci(mirror): split the string guard into refuse and report
 tiers

A guard that can never pass is a guard nobody arms: on the real tree the
single [strings] tier refused with 26 hits (README and the two installers), all of them internal ticket
identifiers and non-production hostnames whose fate is still a decision,
not a defect. The scan now has two tiers in .publish-forbidden:

- [strings-refuse]  mailboxes (support@ spared), arn:aws:, ECR account ids,
                    plus the private tenant needles from --extra-forbidden.
                    A hit refuses, as before.
- [strings-report]  ticket/RFC identifiers and non-production hosts. Hits are
                    counted and printed (per-needle totals, ten most-hit
                    files) but refuse only under --strict.

The list itself is refused (exit 2) on an unknown section header, a needle
in both tiers, or an empty [strings-refuse]. The header regex is loose on
purpose so a misspelt header is refused by name rather than read as a
needle of the section before it.

The workflow gains a `strict` dispatch input (default false) and honours the
PUBLISH_STRICT repository variable for every run, including workflow_run,
which has no inputs; flipping either arms the report tier.

Tests: 34 -> 43 (42 in CI, where the real-gitleaks case skips). Report-tier hit alone -> exit 0 with count and table; the
same under --strict -> exit 1 naming [strings-report (strict)]; refuse-tier
hit -> exit 1 naming [strings-refuse]; duplicate needle, unknown section
and empty refuse tier -> exit 2 with the offender named. The committed list
is exercised needle by needle with inputs written in the test, and the real
tree is now asserted clean (exit 0). Each new check was mutation-proved.

Co-Authored-By: Claude Fable 5.1 
---
 .github/workflows/mirror-publish.yml  |  29 ++++-
 .publish-forbidden                    |  62 +++++----
 scripts/RELEASE_CHECKLIST.md          |   6 +-
 scripts/publish-guard.sh              | 175 ++++++++++++++++++++------
 scripts/tests/publish-guard-verify.sh | 149 +++++++++++++++++-----
 5 files changed, 318 insertions(+), 103 deletions(-)

diff --git a/.github/workflows/mirror-publish.yml b/.github/workflows/mirror-publish.yml
index 68a4f85..dbf23e9 100644
--- a/.github/workflows/mirror-publish.yml
+++ b/.github/workflows/mirror-publish.yml
@@ -22,7 +22,19 @@
 #                      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.
+#                      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.
 #
 # Target
 #   The mirror is named by the Actions VARIABLE `MIRROR_REPO` (a bare repo name
@@ -58,6 +70,10 @@ on:
         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
@@ -84,8 +100,10 @@ jobs:
       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 }}
       VAR_MIRROR: ${{ vars.MIRROR_REPO }}
+      VAR_STRICT: ${{ vars.PUBLISH_STRICT }}
     steps:
       - name: Resolve what to publish
         id: plan
@@ -100,6 +118,10 @@ jobs:
           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
           if [ -n "$TAG" ]; then
             if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
@@ -129,8 +151,9 @@ jobs:
             echo "dry_run=$DRY_RUN"
             echo "ref=$REF"
             echo "prerelease=$PRERELEASE"
+            echo "strict=$STRICT"
           } >>"$GITHUB_OUTPUT"
-          echo "plan: event=$EVENT_NAME tag='${TAG:-}' ref=$REF dry_run=$DRY_RUN prerelease=$PRERELEASE"
+          echo "plan: event=$EVENT_NAME tag='${TAG:-}' ref=$REF dry_run=$DRY_RUN prerelease=$PRERELEASE strict=$STRICT"
 
       - name: Check out the source at the release tag
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -181,10 +204,12 @@ jobs:
       - name: Guard the tree and the release assets
         env:
           TAG: ${{ steps.plan.outputs.tag }}
+          STRICT: ${{ steps.plan.outputs.strict }}
         run: |
           set -uo pipefail
           args=(--source . --out "$RUNNER_TEMP/stage" --extra-forbidden "$RUNNER_TEMP/tenants.txt")
           [ -z "$TAG" ] || args+=(--assets "$RUNNER_TEMP/assets")
+          [ "$STRICT" != "true" ] || args+=(--strict)
           bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-tree.log"
           rc=${PIPESTATUS[0]}
           {
diff --git a/.publish-forbidden b/.publish-forbidden
index 7d4b7df..41f4d35 100644
--- a/.publish-forbidden
+++ b/.publish-forbidden
@@ -1,17 +1,24 @@
 # .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. Three sections; `#` starts a comment.
+# 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]  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).
-#   [allow]    exact tokens removed from a line before a [strings] needle is
-#              re-tested, so a line is spared only when the allowed token was
-#              the whole reason it hit.
+#   [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.
@@ -37,19 +44,7 @@ __pycache__
 *.key
 kubeconfig*
 
-[strings]
-# Internal tracker and RFC identifiers — a reader of the mirror cannot open them.
-backend#
-rfcs#
-RFC-0
-RFC-BACKEND
-e2e-test-agent#
-tracebloc/backend
-# Non-production tracebloc hosts.
-dev-api\.tracebloc\.io
-stg-api\.tracebloc\.io
-dev\.tracebloc\.io
-stg\.tracebloc\.io
+[strings-refuse]
 # Mailboxes (the public support address is spared under [allow]).
 [A-Za-z0-9._%+-]+@tracebloc\.io
 # AWS account identifiers and ARNs.
@@ -60,8 +55,25 @@ arn:aws:
 # 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. The guard
-# refuses to run the scan when that list is missing or empty.
+# 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/scripts/RELEASE_CHECKLIST.md b/scripts/RELEASE_CHECKLIST.md
index 78f8ddc..5dc88d4 100644
--- a/scripts/RELEASE_CHECKLIST.md
+++ b/scripts/RELEASE_CHECKLIST.md
@@ -36,7 +36,11 @@ have to reverse-engineer the surface area on release day.
    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.
+   `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.
 
 GitHub Releases plus the cosign-verified `install.sh` are the
 install path — a Homebrew tap and the `install.tracebloc.io`
diff --git a/scripts/publish-guard.sh b/scripts/publish-guard.sh
index 702f730..ff7349e 100755
--- a/scripts/publish-guard.sh
+++ b/scripts/publish-guard.sh
@@ -15,22 +15,41 @@
 #    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 `[strings]`: needles (extended
-#                            regex, case-insensitive) that must not appear in
-#                            any staged text file. `[allow]` entries are exact
-#                            tokens spared before a needle is re-tested (a
-#                            public support mailbox beside a rule that bans
-#                            every other mailbox). More needles can be handed
-#                            in privately with --extra-forbidden.
+#    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).
+#                              [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 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,
-#            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.
+#            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.
 #
@@ -38,12 +57,14 @@
 #    publish-guard.sh --source DIR --out DIR
 #                     [--include FILE]           default DIR/.publish-include
 #                     [--forbidden FILE]         default DIR/.publish-forbidden
-#                     [--extra-forbidden FILE]   more [strings] needles (repeat
+#                     [--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
@@ -54,7 +75,7 @@
 # =============================================================================
 set -uo pipefail
 
-SOURCE=""; OUT=""; INCLUDE=""; FORBIDDEN=""; ASSETS=""
+SOURCE=""; OUT=""; INCLUDE=""; FORBIDDEN=""; ASSETS=""; STRICT=0
 EXTRA_FORBIDDEN=()
 while [ "$#" -gt 0 ]; do
   case "$1" in
@@ -64,6 +85,7 @@ while [ "$#" -gt 0 ]; do
     --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
@@ -107,18 +129,41 @@ REPORT="$TMP/report.txt"
 : >"$REPORT"
 
 # ---- list files: strip comments and blanks, keep order ------------------------
-# read_list FILE SECTION — print the entries of SECTION ([paths] / [strings] /
-# [allow]) from a sectioned list file; SECTION "" prints every entry of a file
-# that has no section headers (the allowlist, an --extra-forbidden list).
+# 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" '
+  awk -v want="$2" -v hdr="$SECTION_RE" '
     /^[[:space:]]*(#|$)/ { next }
-    /^\[[A-Za-z-]+\][[:space:]]*$/ { sec = $0; sub(/^\[/, "", sec); sub(/\].*$/, "", sec); 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.
@@ -242,6 +287,7 @@ path_pattern_hits() { # $1 = pattern, reads staged paths on stdin, prints hits
 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
@@ -258,22 +304,37 @@ guard_forbidden_paths() {
 }
 
 # ---- 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_needles n_allow=0 total=0 extra
+  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
-  read_list "$FORBIDDEN" strings >"$TMP/needles.txt"
-  read_list "$FORBIDDEN" allow   >"$TMP/allow.txt"
+  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.txt"
+    read_list "$extra" "" >>"$TMP/needles-refuse.txt"
   done
-  n_needles="$(grep -c . "$TMP/needles.txt" || true)"
+  n_refuse="$(grep -c . "$TMP/needles-refuse.txt" || true)"
+  n_report="$(grep -c . "$TMP/needles-report.txt" || true)"
   n_allow="$(grep -c . "$TMP/allow.txt" || true)"
-  if [ "$n_needles" -eq 0 ]; then cant_tell "$g" "'$FORBIDDEN' has no [strings] entries — a scan with no needles proves nothing"; RAN=$((RAN + 1)); return; fi
 
   # Census of what the scan can and cannot see.
   local n_text=0 n_bin=0 f
@@ -284,14 +345,18 @@ guard_forbidden_strings() {
 
   local -a scan_dirs=("$OUT/tree")
   [ -d "$OUT/assets" ] && scan_dirs+=("$OUT/assets")
-  local shown allow_expr
+  local allow_expr
   allow_expr="$(paste -sd'|' "$TMP/allow.txt")"
-  while IFS= read -r needle; do
+  # needle_hits NEEDLE — 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.
+  needle_hits() {
+    local needle="$1" 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 cant_tell "$g" "grep exited $rc on needle '$needle': $(tr '\n' ' ' <"$TMP/grep.err")"; RAN=$((RAN + 1)); return; fi
-    [ "$rc" -eq 0 ] || continue
+    if [ "$rc" -ge 2 ]; then GREP_ERR="grep exited $rc on needle '$needle': $(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.
     # Split each hit into its location and its text; only the TEXT is re-tested,
@@ -302,21 +367,49 @@ guard_forbidden_strings() {
       sed -E "s#$allow_expr# #g" "$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 cant_tell "$g" "re-test after [allow] stripping exited $rc on needle '$needle'"; RAN=$((RAN + 1)); return; fi
-    awk 'NR == FNR { keep[$1] = 1; next } (FNR in keep)' "$TMP/kept.txt" "$TMP/locs.txt" >"$TMP/hits.txt"
-    hits="$(grep -c . "$TMP/hits.txt" || true)"
-    [ "$hits" -gt 0 ] || continue
-    total=$((total + hits))
-    refuse "$g" "needle '$needle' found in $hits staged line(s):"
-    { echo "needle '$needle':"; sed "s|^$OUT/||" "$TMP/hits.txt"; } >>"$REPORT"
-    shown="$(sed "s|^$OUT/||" "$TMP/hits.txt" | head -20 | sed 's/^/    /')"
-    printf '%s\n' "$shown"
-    [ "$hits" -le 20 ] || echo "    … and $((hits - 20)) more (full list in publish-guard-report.txt)"
-  done <"$TMP/needles.txt"
-  if [ "$total" -eq 0 ]; then
-    note "$g" "clean ($n_needles needle(s), $n_allow allow token(s); $n_text text file(s) scanned, $n_bin binary file(s) opaque to this scan)"
+    if [ "$rc" -ge 2 ]; then GREP_ERR="re-test after [allow] stripping exited $rc on needle '$needle'"; 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
+  }
+
+  local tier label n_refused=0 n_reported=0
+  : >"$TMP/report-locs.txt"
+  for tier in refuse report; do
+    while IFS= read -r needle; do
+      needle_hits "$needle" || { cant_tell "$g" "$GREP_ERR"; RAN=$((RAN + 1)); return; }
+      hits="$(grep -c . "$TMP/hits.txt" || true)"
+      [ "$hits" -gt 0 ] || continue
+      { echo "[strings-$tier] needle '$needle':"; cat "$TMP/hits.txt"; } >>"$REPORT"
+      if [ "$tier" = refuse ]; then
+        n_refused=$((n_refused + hits)); label="strings-refuse"
+      else
+        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] needle '$needle' found in $hits staged line(s) — counted, not refused (--strict refuses)"
+          continue
+        fi
+      fi
+      refuse "$g" "[$label] needle '$needle' 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" "$total hit(s) across $n_needles needle(s); $n_text text file(s) scanned, $n_bin binary file(s) opaque to this scan"
+    note "$g" "$n_refused refuse-tier hit(s), $n_reported report-tier hit(s) counted ($tally)"
   fi
   RAN=$((RAN + 1))
 }
diff --git a/scripts/tests/publish-guard-verify.sh b/scripts/tests/publish-guard-verify.sh
index 17e0da3..1cdde7c 100755
--- a/scripts/tests/publish-guard-verify.sh
+++ b/scripts/tests/publish-guard-verify.sh
@@ -53,7 +53,8 @@ write_include()   { printf '# fixture allowlist\n' >"$SRC/.publish-include"; pri
 write_forbidden() {
   {
     printf '[paths]\n'; printf '%s\n' 'tests/' 'scripts/tests/' 'Makefile' 'CLAUDE.md' '.github/' '*.go' 'go.mod' 'kubeconfig*'
-    printf '\n[strings]\n'; printf '%s\n' 'backend#' 'RFC-0' 'dev-api\.tracebloc\.io' '[A-Za-z0-9._%+-]+@tracebloc\.io'
+    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"
 }
@@ -93,7 +94,7 @@ 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 (4 needle(s), 1 allow token(s); 3 text file(s) scanned, 0 binary" && has "[gitleaks] clean" \
+   && 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
@@ -120,35 +121,33 @@ 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]\nbackend#\n' >"$SRC/.publish-forbidden"; guard
+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]\nbackend#\n' >"$SRC/.publish-forbidden"; guard
+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 ---------------------------------------------------------
-fresh ref; plant README.md 'see backend#1234' && guard
-if [ "$RC" -eq 1 ] && has "[forbidden-strings] REFUSED — needle 'backend#' found in 1 staged line(s):" && has "    tree/README.md:2" && ! has "see backend#1234"; then
-  ok "mutation: an internal tracker reference is refused; file:line named, text not echoed"
-else bad "tracker ref (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 host; plant docs/usage.md 'API dev-api.tracebloc.io' && guard
-if [ "$RC" -eq 1 ] && has "needle 'dev-api\.tracebloc\.io' found in 1 staged line(s):" && has "tree/docs/usage.md:2"; then ok "mutation: a non-production hostname is refused"; else bad "hostname (rc=$RC): $OUTPUT"; fi
-
-fresh case; plant README.md 'BACKEND#7' && guard
-if [ "$RC" -eq 1 ] && has "needle 'backend#' found in 1 staged line(s)"; then ok "needles match case-insensitively"; else bad "case (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 "needle '[A-Za-z0-9._%+-]+@tracebloc\.io' found in 1 staged line(s):" && has "tree/README.md:2"; then
+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
 
 fresh extra; printf 'planted-tenant\n' >"$ROOT/extra/tenants.txt"; plant docs/usage.md 'for Planted-Tenant' && guard --extra-forbidden "$ROOT/extra/tenants.txt"
-if [ "$RC" -eq 1 ] && has "needle 'planted-tenant' found in 1 staged line(s):" && has "tree/docs/usage.md:2" && has "across 5 needle(s)"; then
-  ok "mutation: a private needle from --extra-forbidden is enforced"
+if [ "$RC" -eq 1 ] && has "[strings-refuse] needle 'planted-tenant' found in 1 staged line(s):" && has "tree/docs/usage.md:2" && has "(3 refuse + 3 report needle(s)"; then
+  ok "mutation: a private needle from --extra-forbidden joins the refuse tier"
 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"
@@ -157,16 +156,78 @@ if [ "$RC" -eq 2 ] && has "[forbidden-strings] COULD NOT TELL — extra forbidde
 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# backend#42\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 "needle 'backend#' found in 1 staged line(s):" && has "assets/install.sh:2"; then
-  ok "an internal reference inside a release asset is refused with the asset named"
+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\000backend#1\000' >"$ROOT/binary/assets/tracebloc-linux-amd64"; guard --assets "$ROOT/binary/assets"
+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
 
-fresh nostrings; printf '[paths]\ntests/\n' >"$SRC/.publish-forbidden"; guard
-if [ "$RC" -eq 2 ] && has "[forbidden-strings] COULD NOT TELL" && has "has no [strings] entries"; then ok "no [strings] entries is could-not-tell"; else bad "no strings (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
@@ -212,11 +273,29 @@ else
 fi
 
 # ---- the repo's OWN lists, fed inputs written here -----------------------------------------
-fresh real-ref; cp "$REPO/.publish-forbidden" "$SRC/.publish-forbidden"; plant README.md 'rationale in backend#1' && guard
-if [ "$RC" -eq 1 ] && has "needle 'backend#' found in 1 staged line(s):" && has "tree/README.md:2"; then ok "the committed .publish-forbidden refuses an internal reference"; else bad "real forbidden ref (rc=$RC): $OUTPUT"; fi
-
-fresh real-host; cp "$REPO/.publish-forbidden" "$SRC/.publish-forbidden"; guard; a="$RC"; rm -rf "$OUT"; plant docs/usage.md 'https://stg-api.tracebloc.io/' && guard
-if [ "$a" -eq 0 ] && [ "$RC" -eq 1 ] && has "needle 'stg-api\.tracebloc\.io' found in 1 staged line(s):"; then ok "the committed .publish-forbidden spares support@ and refuses a non-production host"; else bad "real forbidden host (a=$a rc=$RC): $OUTPUT"; fi
+# Inputs written independently of .publish-forbidden; the summary line's needle
+# counts are asserted so a needle added to either tier without an input here
+# reddens this harness.
+fresh real-refuse; cp "$REPO/.publish-forbidden" "$SRC/.publish-forbidden"; guard; a="$RC"; b="$OUTPUT"; rm -rf "$OUT"
+plant README.md 'ask someone@tracebloc.io' && plant docs/usage.md '# role arn:aws:iam::000000000000:role/x' && plant LICENSE 'IMG=000000000000.dkr.ecr.eu-central-1.amazonaws.com/x' && guard
+missing=""; for needle in '[A-Za-z0-9._%+-]+@tracebloc\.io' 'arn:aws:' '[0-9]{12}\.dkr\.ecr\.'; do has "REFUSED — [strings-refuse] needle '$needle' found in 1 staged line(s):" || missing="$missing $needle"; done
+if [ "$a" -eq 0 ] && [[ "$b" == *"[forbidden-strings] clean (3 refuse + 10 report needle(s), 1 allow token(s)"* ]] && [ "$RC" -eq 1 ] && [ -z "$missing" ] && has "[forbidden-strings] 3 refuse-tier hit(s), 0 report-tier hit(s) counted"; then
+  ok "the committed .publish-forbidden spares support@ and refuses every refuse-tier needle by name"
+else bad "real refuse tier (a=$a rc=$RC, not refused:$missing): $b // $OUTPUT"; fi
+
+fresh real-report; cp "$REPO/.publish-forbidden" "$SRC/.publish-forbidden"
+plant README.md 'see backend#1 and rfcs#2 and RFC-0003 and RFC-BACKEND-0004' && plant README.md 'see e2e-test-agent#5 and tracebloc/backend' \
+  && plant docs/usage.md 'A=https://dev-api.tracebloc.io/ B=https://stg-api.tracebloc.io/' && plant docs/usage.md 'C=https://dev.tracebloc.io/ D=https://stg.tracebloc.io/' && guard
+a="$RC"; b="$OUTPUT"; missing=""
+for needle in 'backend#' 'rfcs#' 'RFC-0' 'RFC-BACKEND' 'e2e-test-agent#' 'tracebloc/backend' 'dev-api\.tracebloc\.io' 'stg-api\.tracebloc\.io' 'dev\.tracebloc\.io' 'stg\.tracebloc\.io'; do
+  has "[strings-report] needle '$needle' found in 1 staged line(s) — counted, not refused" || missing="$missing $needle"
+done
+rm -rf "$OUT"; guard --strict
+if [ "$a" -eq 0 ] && [ -z "$missing" ] && [[ "$b" == *"[strings-report] 10 hit(s) in 2 file(s); most-hit files:"* ]] && [[ "$b" == *"0 refuse-tier hit(s), 10 report-tier hit(s) counted (3 refuse + 10 report needle(s)"* ]] \
+   && [ "$RC" -eq 1 ] && has "REFUSED — [strings-report (strict)] needle 'backend#' found in 1 staged line(s):" && has "tree/README.md:2" \
+   && has "REFUSED — [strings-report (strict)] needle 'stg\.tracebloc\.io' found in 1 staged line(s):" && has "tree/docs/usage.md:3"; then
+  ok "the committed .publish-forbidden counts every report-tier needle by name, and --strict refuses them"
+else bad "real report tier (a=$a rc=$RC, not counted:$missing): $b // $OUTPUT"; fi
 
 fresh real-paths; cp "$REPO/.publish-forbidden" "$SRC/.publish-forbidden"
 add_file go.sum 'h1:'; add_file STYLE.md 's'; add_file .cursor/BUGBOT.md 'b'; add_file secret.pem 'p'; add_file .env.local 'e'; add_file docs/migration-tools/t.sh 't'; commit
@@ -228,16 +307,18 @@ done
 if [ "$RC" -eq 1 ] && [ -z "$missing" ]; then ok "the committed .publish-forbidden refuses every forbidden path class by name"; else bad "real forbidden paths (rc=$RC, not refused:$missing)"; fi
 
 # The real repo through its real allowlist: README, LICENSE and docs/*.md, no
-# source, no build files, no workflows. The strings verdict is NOT asserted —
-# the tree carries a known backlog of internal references in the installers
-# tracked separately — only that the guard could evaluate it.
+# source, no build files, no workflows. Asserted CLEAN: the refuse tier must
+# hold on the real deliverable; the report tier (the known backlog of internal
+# references in README and the installers) is counted, not refused, until
+# --strict is the policy. A refuse-tier needle landing in a deliverable file
+# reddens this harness — which is the point.
 OUTPUT="$(bash "$GUARD" --source "$REPO" --out "$ROOT/real/out" 2>&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" -le 1 ] && ! has "COULD NOT TELL" && has "[forbidden-paths] clean" && [ -z "$missing" ] && [ -z "$present" ]; then
-  ok "the committed .publish-include stages README/LICENSE/docs of the real repo and no source"
+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 30 ]
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 40 ]

From a0d42315eea712ebc1d371639f3514b0aa30c24d Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 16:52:05 +0200
Subject: [PATCH 3/7] ci(mirror): run the tooling from this commit, treat the
 tag as data, keep prereleases off the default branch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Review findings on the mirror-publish workflow, each with its test:

- Untrusted checkout. The job checked out the release tag and then ran
  scripts/publish-guard.sh and publish-mirror.sh from it while the job went
  on to mint an App token. Now the one actions/checkout has no ref (the
  workflow's own commit), and the release tag is fetched separately into a
  detached worktree under RUNNER_TEMP as data — staged and scanned, never
  executed. Before the fetch the plan step requires the release object's
  tag_name to equal the run's tag and takes the expected commit from
  workflow_run.head_sha (a dispatch asks the API); the fetched tag must
  resolve to exactly that commit or the run refuses. The guard reads the
  allowlist and forbidden list from the tooling checkout, whatever the tag
  carries.
- Prerelease overwrote the mirror's default branch. workflow_run always
  publishes, and the tree push never looked at PRERELEASE. The plan step now
  derives publish_tree=false for a prerelease and says why; the default
  branch push is gated on it; the release is still created, marked
  prerelease, pinned to the mirror's current default-branch head — an empty
  mirror is refused rather than given an RC as its first content.
- Captured output hid refusals. `target` and `tree` ran through `$(...)`,
  so under set -e their ::error:: lines never reached the log. Both now run
  directly and write their results (repo=/name=, result=/sha=) through a new
  --output FILE option, which the workflow points at $GITHUB_OUTPUT.
- The gitleaks download carries --tlsv1.2 like every other privileged fetch
  in this repository.

scripts/tests/mirror-publish-workflow-verify.sh executes the plan, src,
target and keep step bodies read out of the workflow itself (gh shimmed, the
tag fetch against a real bare repo) and pins the shape — no checkout ref,
the tree push gated, the release step not, no captured publisher, one pinned
tag fetch — with mutations asserted to change the document before they are
judged; build.yml runs it beside the other harnesses.
publish-mirror-verify.sh covers --output for target and tree, including that
a refusal writes nothing and annotates stdout. Each new check was
mutation-proved against the real workflow and script. The release checklist
notes the trust shape and the prerelease behaviour.

Co-Authored-By: Claude Fable 5.1 
---
 .github/workflows/build.yml                   |   9 +
 .github/workflows/mirror-publish.yml          | 147 ++++++--
 scripts/RELEASE_CHECKLIST.md                  |   6 +
 scripts/publish-mirror.sh                     |  38 +-
 .../tests/mirror-publish-workflow-verify.sh   | 333 ++++++++++++++++++
 scripts/tests/publish-mirror-verify.sh        |  29 +-
 6 files changed, 532 insertions(+), 30 deletions(-)
 create mode 100644 scripts/tests/mirror-publish-workflow-verify.sh

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 988878f..ca61794 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -97,6 +97,8 @@ jobs:
           shellcheck --shell=bash --severity=warning scripts/publish-mirror.sh
           shellcheck --shell=bash --severity=error scripts/tests/publish-guard-verify.sh
           shellcheck --shell=bash --severity=error scripts/tests/publish-mirror-verify.sh
+          shellcheck --shell=bash --severity=error scripts/tests/mirror-publish-workflow-verify.sh
+          bash -n scripts/tests/mirror-publish-workflow-verify.sh
           bash -n scripts/publish-guard.sh
           bash -n scripts/publish-mirror.sh
       # format.sh's own fail-closed properties. Formatters are stubbed, so this is
@@ -143,6 +145,13 @@ jobs:
         run: bash scripts/tests/publish-guard-verify.sh
       - name: Mirror-publish publisher harness (target / tree / release)
         run: bash scripts/tests/publish-mirror-verify.sh
+      # The decisions mirror-publish.yml takes in its own step bodies — a
+      # prerelease keeps the mirror's default branch, the release tag is fetched
+      # as data only at the expected commit, no checkout takes an untrusted
+      # ref, a publisher refusal reaches the step log. The step bodies are read
+      # out of the YAML and executed with `gh` shimmed, so this is hermetic.
+      - name: Mirror-publish workflow harness (step bodies / shape / mutations)
+        run: bash scripts/tests/mirror-publish-workflow-verify.sh
 
   test:
     timeout-minutes: 15
diff --git a/.github/workflows/mirror-publish.yml b/.github/workflows/mirror-publish.yml
index dbf23e9..cdeed50 100644
--- a/.github/workflows/mirror-publish.yml
+++ b/.github/workflows/mirror-publish.yml
@@ -17,7 +17,8 @@
 #                      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).
+#                      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).
@@ -36,6 +37,25 @@
 #   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.
+#
 # 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
@@ -102,9 +122,17 @@ jobs:
       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:
@@ -122,43 +150,87 @@ jobs:
           # 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
+          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.
-            if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json isDraft,isPrerelease >"$RUNNER_TEMP/release.json"; then
+            # 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")"
-            REF="$TAG"
+            # 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."
+            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
-            REF="$GITHUB_SHA"
           fi
           {
             echo "tag=$TAG"
             echo "dry_run=$DRY_RUN"
-            echo "ref=$REF"
+            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:-}' ref=$REF dry_run=$DRY_RUN prerelease=$PRERELEASE strict=$STRICT"
+          echo "plan: event=$EVENT_NAME tag='${TAG:-}' expect_sha=${EXPECT_SHA:-} dry_run=$DRY_RUN prerelease=$PRERELEASE publish_tree=$PUBLISH_TREE strict=$STRICT"
 
-      - name: Check out the source at the release tag
-        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
-        with:
-          ref: ${{ steps.plan.outputs.ref }}
+      - 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
@@ -171,7 +243,7 @@ jobs:
         run: |
           set -euo pipefail
           mkdir -p "$RUNNER_TEMP/bin"
-          curl -fsSL --retry 3 --connect-timeout 10 --max-time 120 \
+          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 -
@@ -202,12 +274,18 @@ jobs:
           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.
         env:
           TAG: ${{ steps.plan.outputs.tag }}
           STRICT: ${{ steps.plan.outputs.strict }}
+          SRC_DIR: ${{ steps.src.outputs.dir }}
         run: |
           set -uo pipefail
-          args=(--source . --out "$RUNNER_TEMP/stage" --extra-forbidden "$RUNNER_TEMP/tenants.txt")
+          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)
           bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-tree.log"
@@ -234,14 +312,14 @@ jobs:
           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
-          REPO="$(bash scripts/publish-mirror.sh target --mirror "${INPUT_MIRROR:-$VAR_MIRROR}" --source-repo "$GITHUB_REPOSITORY")"
-          echo "repo=$REPO" >>"$GITHUB_OUTPUT"
-          echo "name=${REPO#*/}" >>"$GITHUB_OUTPUT"
-          echo "mirror: $REPO"
+          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'
@@ -272,7 +350,9 @@ jobs:
           cat "$RUNNER_TEMP/mirror.json"
 
       - name: Push the tree to the mirror's default branch
-        if: steps.plan.outputs.dry_run != 'true'
+        # Stable releases only: a prerelease 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 }}
@@ -286,9 +366,30 @@ jobs:
           # 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'
-          OUT="$(bash scripts/publish-mirror.sh tree --stage "$RUNNER_TEMP/stage/tree" --repo "$REPO" --branch "$BRANCH" --message "Publish $TAG")"
-          echo "$OUT"
-          echo "sha=${OUT#* }" >>"$GITHUB_OUTPUT"
+          # 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: Prerelease — keep the mirror's default branch, pin the release to its head
+        # Nothing was pushed, so the release is created at the commit the
+        # mirror's default branch already has (the last stable publish). An
+        # empty mirror has no such commit: a prerelease 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' is a prerelease and the mirror has no commit on '$BRANCH' to pin it to — a prerelease cannot be the first publish to an empty mirror; publish a stable release first."
+            exit 1
+          fi
+          echo "sha=$SHA" >>"$GITHUB_OUTPUT"
+          echo "prerelease $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'
@@ -296,7 +397,7 @@ jobs:
           GH_TOKEN: ${{ steps.token.outputs.token }}
           REPO: ${{ steps.target.outputs.repo }}
           TAG: ${{ steps.plan.outputs.tag }}
-          SHA: ${{ steps.push.outputs.sha }}
+          SHA: ${{ steps.push.outputs.sha || steps.keep.outputs.sha }}
           PRERELEASE: ${{ steps.plan.outputs.prerelease }}
         run: |
           set -euo pipefail
diff --git a/scripts/RELEASE_CHECKLIST.md b/scripts/RELEASE_CHECKLIST.md
index 5dc88d4..38d6ec2 100644
--- a/scripts/RELEASE_CHECKLIST.md
+++ b/scripts/RELEASE_CHECKLIST.md
@@ -41,6 +41,12 @@ have to reverse-engineer the surface area on release day.
    (internal ticket references, non-production hostnames) are counted
    and printed with the most-hit files, and refuse only under the
    `strict` input or the `PUBLISH_STRICT=true` repository variable.
+   The guard and publisher run from the workflow's own commit; the
+   release tag is fetched separately as data and refused unless it
+   resolves to the commit the Release run ran on. A prerelease
+   (`-rc.N`) mirrors only its GitHub release, marked prerelease and
+   pinned to the mirror's current default-branch head — the mirror's
+   default branch keeps the last stable release.
 
 GitHub Releases plus the cosign-verified `install.sh` are the
 install path — a Homebrew tap and the `install.tracebloc.io`
diff --git a/scripts/publish-mirror.sh b/scripts/publish-mirror.sh
index 18f5e21..c15d6be 100755
--- a/scripts/publish-mirror.sh
+++ b/scripts/publish-mirror.sh
@@ -6,18 +6,27 @@
 #  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]
+#             [--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 `.
+#             `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]
@@ -43,13 +52,23 @@ die2() { echo "::error::publish-mirror: COULD NOT TELL — $1 (never publishes)"
 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=""
+  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
@@ -63,11 +82,12 @@ cmd_target() {
   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=""
+  local stage="" repo="" branch="" message="" remote="" output=""
   while [ "$#" -gt 0 ]; do
     case "$1" in
       --stage)   stage="${2:-}"; shift 2 ;;
@@ -75,6 +95,7 @@ cmd_tree() {
       --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
@@ -113,15 +134,20 @@ cmd_tree() {
   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
-    echo "unchanged $(git -C "$work" rev-parse HEAD)"
+    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")"
-  echo "pushed $(git -C "$work" rev-parse HEAD)"
+  sha="$(git -C "$work" rev-parse HEAD)"
+  emit_output "$output" "result=pushed" "sha=$sha"
+  echo "pushed $sha"
 }
 
 cmd_release() {
diff --git a/scripts/tests/mirror-publish-workflow-verify.sh b/scripts/tests/mirror-publish-workflow-verify.sh
new file mode 100644
index 0000000..c84ea52
--- /dev/null
+++ b/scripts/tests/mirror-publish-workflow-verify.sh
@@ -0,0 +1,333 @@
+#!/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; a 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")
+#    * 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 ... --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 "*)
+    [ "${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
+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
+  : >"$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  [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() {
+  local body="$ROOT/step-$1.sh"
+  if ! step_run "$WF" "$1" >"$body"; then OUTPUT="$(cat "$body")"; RC=2; return; fi
+  local dir="${2:-$WORK}"
+  OUTPUT="$(PATH="$SHIM:$PATH" bash -c "cd '$dir' && bash '$body'" 2>&1)"; RC=$?
+}
+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_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" && ! has "::notice::"; then
+  ok "plan: a stable release from workflow_run publishes tree and release, pinned to head_sha"
+else bad "plan stable (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" ] \
+   && 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, and the log says why"
+else bad "plan prerelease (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_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"
+  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 "$WORK" rev-parse 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" == *"prerelease v1.2.3-rc.1: default branch 'main' left untouched"* ]] \
+   && [ "$RC" -eq 1 ] && has "::error::'v1.2.3-rc.1' is a prerelease and the mirror has no commit on 'main' to pin it to — a prerelease cannot be the first publish to an empty mirror" && [ ! -s "$GITHUB_OUTPUT" ]; then
+  ok "keep: a prerelease 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
+
+# ---- 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
+
+echo
+printf 'mirror-publish-workflow-verify: %d passed, %d failed\n' "$PASS" "$FAIL"
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 17 ]
diff --git a/scripts/tests/publish-mirror-verify.sh b/scripts/tests/publish-mirror-verify.sh
index 1eb1e01..5042708 100755
--- a/scripts/tests/publish-mirror-verify.sh
+++ b/scripts/tests/publish-mirror-verify.sh
@@ -60,6 +60,17 @@ if [ "$RC" -eq 0 ] && [ "$OUTPUT" = "tracebloc/cli-public" ]; then ok "target: a
 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"
@@ -91,6 +102,22 @@ if [ "$a" -eq 2 ] && [[ "$o1" == *"holds no files"* ]] && [ "$RC" -eq 2 ] && has
 
 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
@@ -125,4 +152,4 @@ 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 14 ]
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 19 ]

From 512223b8c521b7fe9b2a461159aa1965402d6de3 Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 16:58:34 +0200
Subject: [PATCH 4/7] test(mirror): pin the fixture origin's HEAD so the
 tag-fetch case runs on a fresh runner
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The workflow harness's bare origin relied on init.defaultBranch: unset on the
runner, its HEAD pointed at a `master` nobody pushed, the clone had an unborn
HEAD, and `rev-parse HEAD` handed the src step the literal word HEAD as the
expected commit — the case failed with "cut at HEAD" instead of proving the
pinned fetch (Installer (shell) on the previous push). The bare HEAD is now
set to main explicitly and the commit is read from the seed repository;
reproduced locally with GIT_CONFIG_KEY_0=init.defaultBranch
GIT_CONFIG_VALUE_0=master before and after.

Co-Authored-By: Claude Fable 5.1 
---
 scripts/tests/mirror-publish-workflow-verify.sh | 7 ++++++-
 1 file changed, 6 insertions(+), 1 deletion(-)

diff --git a/scripts/tests/mirror-publish-workflow-verify.sh b/scripts/tests/mirror-publish-workflow-verify.sh
index c84ea52..6d06a42 100644
--- a/scripts/tests/mirror-publish-workflow-verify.sh
+++ b/scripts/tests/mirror-publish-workflow-verify.sh
@@ -161,6 +161,11 @@ make_origin() { # a bare origin with one commit tagged v1.2.3 (annotated); WORK
   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
@@ -168,7 +173,7 @@ make_origin() { # a bare origin with one commit tagged v1.2.3 (annotated); WORK
   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 "$WORK" rev-parse HEAD
+  git -C "$seed" rev-parse --verify HEAD
 }
 
 reset_env; sha="$(make_origin)"; export TAG=v1.2.3 EXPECT_SHA="$sha"

From 0528177f740b3224bf96c0b2a26efcc9ad8b88be Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 17:27:16 +0200
Subject: [PATCH 5/7] fix(guard): strip [allow] tokens as whole words, name
 private needles by number only
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Two review findings on scripts/publish-guard.sh (kept byte-identical with the
client repository's copy):

- The [allow] pass removed a token with an unanchored, case-sensitive sed
  replace before re-testing the needle. A mailbox that merely ENDS in the
  public support address (devsupport@…) lost the token, the mailbox rule no
  longer matched, and the internal address could ship; a differently-cased
  public address was refused. The strip now matches the token as a whole
  word — not the tail of a longer local part, not the head of a longer
  domain, a sentence-ending dot still a boundary — and case-insensitively,
  as the scan itself matches.
- Refuse-tier hits printed the needle, and the workflow tees that log into
  the run summary. The needles from --extra-forbidden are the identifiers
  kept out of the committed list because this repository is public, so one
  deliverable hit would have published them in the log. Private needles are
  now scanned in their own pass and named `private needle #N` in every line
  the guard prints or writes (stdout, the report file, grep-error text); the
  committed needles are still named by pattern.

publish-guard-verify.sh: devsupport@ is refused, Support@Tracebloc.io. at a
sentence end passes, and the tenant case asserts the redacted name and that
the pattern appears neither in the output nor in publish-guard-report.txt.
Each check was mutation-proved by restoring the old strip and the old label
in the script and watching only its named cases redden.

Co-Authored-By: Claude Fable 5.1 
---
 scripts/publish-guard.sh              | 58 ++++++++++++++++++---------
 scripts/tests/publish-guard-verify.sh | 19 +++++++--
 2 files changed, 56 insertions(+), 21 deletions(-)

diff --git a/scripts/publish-guard.sh b/scripts/publish-guard.sh
index ff7349e..ef1aae2 100755
--- a/scripts/publish-guard.sh
+++ b/scripts/publish-guard.sh
@@ -22,7 +22,11 @@
 #                                                (mailboxes, cloud account
 #                                                identifiers; the private
 #                                                needles from --extra-forbidden
-#                                                join this tier).
+#                                                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
@@ -34,7 +38,10 @@
 #                                                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).
+#                            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
@@ -330,9 +337,10 @@ guard_forbidden_strings() {
   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-refuse.txt"
+    read_list "$extra" "" >>"$TMP/needles-private.txt"
   done
-  n_refuse="$(grep -c . "$TMP/needles-refuse.txt" || true)"
+  : >>"$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)"
 
@@ -347,51 +355,65 @@ guard_forbidden_strings() {
   [ -d "$OUT/assets" ] && scan_dirs+=("$OUT/assets")
   local allow_expr
   allow_expr="$(paste -sd'|' "$TMP/allow.txt")"
-  # needle_hits NEEDLE — 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.
+  # 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" rc
+    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 needle '$needle': $(tr '\n' ' ' <"$TMP/grep.err")"; return 2; fi
+    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#$allow_expr# #g" "$TMP/texts.txt" >"$TMP/texts2.txt" && mv "$TMP/texts2.txt" "$TMP/texts.txt"
+      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 needle '$needle'"; return 2; fi
+    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
   }
 
-  local tier label n_refused=0 n_reported=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 report; do
+  for tier in refuse private report; do
+    k=0
     while IFS= read -r needle; do
-      needle_hits "$needle" || { cant_tell "$g" "$GREP_ERR"; RAN=$((RAN + 1)); return; }
+      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
-      { echo "[strings-$tier] needle '$needle':"; cat "$TMP/hits.txt"; } >>"$REPORT"
-      if [ "$tier" = refuse ]; then
+      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] needle '$needle' found in $hits staged line(s) — counted, not refused (--strict refuses)"
+          note "$g" "[strings-report] $shown found in $hits staged line(s) — counted, not refused (--strict refuses)"
           continue
         fi
       fi
-      refuse "$g" "[$label] needle '$needle' found in $hits staged line(s):"
+      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"
diff --git a/scripts/tests/publish-guard-verify.sh b/scripts/tests/publish-guard-verify.sh
index 1cdde7c..7ea5483 100755
--- a/scripts/tests/publish-guard-verify.sh
+++ b/scripts/tests/publish-guard-verify.sh
@@ -145,9 +145,22 @@ if [ "$a" -eq 0 ] && [ "$RC" -eq 1 ] && has "[strings-refuse] needle '[A-Za-z0-9
   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"
-if [ "$RC" -eq 1 ] && has "[strings-refuse] needle 'planted-tenant' found in 1 staged line(s):" && has "tree/docs/usage.md:2" && has "(3 refuse + 3 report needle(s)"; then
-  ok "mutation: a private needle from --extra-forbidden joins the refuse tier"
+# 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"
@@ -321,4 +334,4 @@ else bad "real allowlist (rc=$RC, missing:$missing, staged-but-forbidden:$presen
 
 echo
 printf 'publish-guard-verify: %d passed, %d failed\n' "$PASS" "$FAIL"
-[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 40 ]
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 42 ]

From 58571a5c909f0b4c6c887384e8c54ce2b686a9bc Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 17:48:43 +0200
Subject: [PATCH 6/7] fix(mirror): push the tree only for the newest stable
 release
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Any non-prerelease tag set publish_tree=true, so a Release re-run or a
dispatch of an OLDER stable tag replaced the mirror's default branch with
that tag's README and docs — and, with the release already mirrored,
refused right after, leaving the rollback in place and paired with nothing.

The plan step now asks GitHub for the source repo's newest stable release
(releases/latest) whenever the tag is not a prerelease. A tag that is not
that release mirrors only its GitHub release, pinned like a prerelease to
the default branch's current head, and the log says why. If the newest
release cannot be read the run refuses rather than guess. The keep step's
name and messages cover both cases now.

mirror-publish-workflow-verify.sh: the gh shim answers releases/latest
from GH_LATEST_TAG / GH_LATEST_RC; new cases for an older stable tag
(publish_tree=false), an unreadable newest release (refused), a prerelease
not consulting it, and a plan-body mutation that disarms the comparison —
the older-tag case catches it. 20 cases.

Co-Authored-By: Claude Fable 5.1 
---
 .github/workflows/mirror-publish.yml          | 45 ++++++++--
 .../tests/mirror-publish-workflow-verify.sh   | 87 ++++++++++++++-----
 2 files changed, 99 insertions(+), 33 deletions(-)

diff --git a/.github/workflows/mirror-publish.yml b/.github/workflows/mirror-publish.yml
index cdeed50..4a80101 100644
--- a/.github/workflows/mirror-publish.yml
+++ b/.github/workflows/mirror-publish.yml
@@ -56,6 +56,17 @@
 #   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
@@ -188,6 +199,20 @@ jobs:
             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
@@ -350,8 +375,9 @@ jobs:
           cat "$RUNNER_TEMP/mirror.json"
 
       - name: Push the tree to the mirror's default branch
-        # Stable releases only: a prerelease never replaces what customers
-        # install from (publish_tree=false, see the plan step).
+        # 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:
@@ -370,11 +396,12 @@ jobs:
           # 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: Prerelease — keep the mirror's default branch, pin the release to its head
-        # Nothing was pushed, so the release is created at the commit the
-        # mirror's default branch already has (the last stable publish). An
-        # empty mirror has no such commit: a prerelease cannot be the first
-        # publish, and the run says so instead of inventing a target.
+      - 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:
@@ -385,11 +412,11 @@ jobs:
         run: |
           set -euo pipefail
           if ! SHA="$(gh api "repos/$REPO/commits/$BRANCH" --jq .sha)" || [[ ! "$SHA" =~ ^[0-9a-f]{40}$ ]]; then
-            echo "::error::'$TAG' is a prerelease and the mirror has no commit on '$BRANCH' to pin it to — a prerelease cannot be the first publish to an empty mirror; publish a stable release first."
+            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 "prerelease $TAG: default branch '$BRANCH' left untouched; release will be pinned to $SHA"
+          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'
diff --git a/scripts/tests/mirror-publish-workflow-verify.sh b/scripts/tests/mirror-publish-workflow-verify.sh
index 6d06a42..3ef4090 100644
--- a/scripts/tests/mirror-publish-workflow-verify.sh
+++ b/scripts/tests/mirror-publish-workflow-verify.sh
@@ -14,9 +14,15 @@
 #  repository over file://.
 #
 #  Pinned, and the review finding each answers:
-#    * a prerelease sets publish_tree=false and says why; a 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 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")
 #    * 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
@@ -48,8 +54,9 @@ 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 ... --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.
+# `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:?}"
@@ -58,8 +65,14 @@ case "${1:-} ${2:-}" in
     [ "${GH_RELEASE_RC:-0}" -eq 0 ] || { echo "release not found" >&2; exit "$GH_RELEASE_RC"; }
     printf '%s\n' "${GH_RELEASE_JSON:?}" ;;
   "api "*)
-    [ "${GH_API_RC:-0}" -eq 0 ] || { echo "HTTP 409: Git Repository is empty" >&2; exit "$GH_API_RC"; }
-    printf '%s\n' "${GH_API_SHA:?}" ;;
+    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
@@ -78,7 +91,7 @@ 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
+  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"
 }
@@ -108,14 +121,16 @@ sys.exit("FAIL: no step with id %r in %s" % (want, path))
 PY
 }
 
-# run_step  [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() {
-  local body="$ROOT/step-$1.sh"
-  if ! step_run "$WF" "$1" >"$body"; then OUTPUT="$(cat "$body")"; RC=2; return; fi
-  local dir="${2:-$WORK}"
+# 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.
+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 '$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"; }
@@ -123,18 +138,33 @@ release_json() { printf '{"tagName":"%s","isDraft":false,"isPrerelease":%s}' "$1
 echo "== mirror-publish.yml step bodies =="
 
 # ---- plan ---------------------------------------------------------------------------
-reset_env; export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A"; GH_RELEASE_JSON="$(release_json v1.2.3 false)"; export GH_RELEASE_JSON
+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" && ! has "::notice::"; then
-  ok "plan: a stable release from workflow_run publishes tree and release, pinned to head_sha"
+   && 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" ] \
+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, and the log says why"
+  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
 
 reset_env; export RUN_HEAD_BRANCH=develop RUN_HEAD_SHA="$SHA_A"
@@ -149,7 +179,7 @@ reset_env; export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA=abc123; 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_RELEASE_JSON="$(release_json v1.2.3 false)"; export GH_RELEASE_JSON
+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
@@ -204,9 +234,9 @@ if [ "$RC" -eq 0 ] && [ "$(out repo)" = example/source-public ] && [ "$(out name
 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" == *"prerelease v1.2.3-rc.1: default branch 'main' left untouched"* ]] \
-   && [ "$RC" -eq 1 ] && has "::error::'v1.2.3-rc.1' is a prerelease and the mirror has no commit on 'main' to pin it to — a prerelease cannot be the first publish to an empty mirror" && [ ! -s "$GITHUB_OUTPUT" ]; then
-  ok "keep: a prerelease is pinned to the mirror's default-branch head; an empty mirror is refused"
+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
 
 # ---- shape: derived from the workflow, one implementation for real and mutated ---------
@@ -333,6 +363,15 @@ if m="$(mutate "s = [s for s in steps if s.get('id') == 'src'][0]; s['run'] = s[
   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
+
 echo
 printf 'mirror-publish-workflow-verify: %d passed, %d failed\n' "$PASS" "$FAIL"
-[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 17 ]
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 20 ]

From 0602d211268157d94a0dec6364b2ebb3220caf15 Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 20:46:18 +0200
Subject: [PATCH 7/7] ci(mirror): refuse a non-boolean isPrerelease; keep the
 guard summary under errexit

Two hardenings in the plan and guard steps of mirror-publish.yml:

- isPrerelease from the release API must be exactly `true` or `false`.
  A missing or malformed value (`jq -r` prints `null`) used to fall
  through into the stable path and arm the tree push; it is now refused
  before the newest-release question is asked.
- The guard step has a stable id (guard-tree) and catches the guard's
  exit status with `rc=0; ... | tee ... || rc=$?`. Actions runs the body
  under `bash -e`, so the earlier `rc=${PIPESTATUS[0]}` never ran on a
  refusal and the step summary stayed empty; the step still exits with
  the guard's own status.

mirror-publish-workflow-verify.sh runs every step body under `bash -e`
(as Actions does) and pins both: the null case, the refusal-to-summary
case, and a mutation for each (accept any isPrerelease -> null publishes
the tree; drop `|| rc=$?` -> summary empty) that the new cases catch.

Co-Authored-By: Claude Fable 5.1 
---
 .github/workflows/mirror-publish.yml          | 14 ++++-
 .../tests/mirror-publish-workflow-verify.sh   | 62 ++++++++++++++++++-
 2 files changed, 72 insertions(+), 4 deletions(-)

diff --git a/.github/workflows/mirror-publish.yml b/.github/workflows/mirror-publish.yml
index 4a80101..66f56d7 100644
--- a/.github/workflows/mirror-publish.yml
+++ b/.github/workflows/mirror-publish.yml
@@ -184,6 +184,12 @@ jobs:
               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.
@@ -303,6 +309,10 @@ jobs:
         # 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 }}
@@ -313,8 +323,8 @@ jobs:
                 --out "$RUNNER_TEMP/stage" --extra-forbidden "$RUNNER_TEMP/tenants.txt")
           [ -z "$TAG" ] || args+=(--assets "$RUNNER_TEMP/assets")
           [ "$STRICT" != "true" ] || args+=(--strict)
-          bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-tree.log"
-          rc=${PIPESTATUS[0]}
+          rc=0
+          bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-tree.log" || rc=$?
           {
             echo "## Mirror publish — tree${TAG:+ + release $TAG}"
             echo
diff --git a/scripts/tests/mirror-publish-workflow-verify.sh b/scripts/tests/mirror-publish-workflow-verify.sh
index 3ef4090..63f6741 100644
--- a/scripts/tests/mirror-publish-workflow-verify.sh
+++ b/scripts/tests/mirror-publish-workflow-verify.sh
@@ -23,6 +23,14 @@
 #      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
@@ -124,11 +132,14 @@ 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 '$body'" 2>&1)"; RC=$?
+  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-; }
@@ -167,6 +178,14 @@ if [ "$RC" -eq 0 ] && [ "$(out prerelease)" = true ] && [ "$(out publish_tree)"
   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
@@ -239,6 +258,26 @@ if [ "$a" -eq 0 ] && [ "$got" = "$SHA_B" ] && [ "$asked" -eq 1 ] && [[ "$o1" ==
   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
@@ -372,6 +411,25 @@ if m="$(mutate "s = [s for s in steps if s.get('id') == 'plan'][0]; s['run'] = s
   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 20 ]
+[ "$FAIL" -eq 0 ] && [ "$PASS" -ge 24 ]