From 5846c18212a438fbed7620d151a5b1bb04ddf4ee 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 (charts, installer, README/LICENSE/docs, the Pages chart index, releases). Nothing reaches it 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 Helm Chart 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. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/mirror-publish.yaml | 327 ++++++++++++++++++ .publish-forbidden | 67 ++++ .publish-include | 29 ++ .publish-include-pages | 7 + scripts/publish-guard.sh | 356 +++++++++++++++++++ scripts/publish-mirror.sh | 173 ++++++++++ scripts/tests/publish-guard.bats | 476 ++++++++++++++++++++++++++ scripts/tests/publish-mirror.bats | 213 ++++++++++++ 8 files changed, 1648 insertions(+) create mode 100644 .github/workflows/mirror-publish.yaml create mode 100644 .publish-forbidden create mode 100644 .publish-include create mode 100644 .publish-include-pages create mode 100755 scripts/publish-guard.sh create mode 100755 scripts/publish-mirror.sh create mode 100644 scripts/tests/publish-guard.bats create mode 100644 scripts/tests/publish-mirror.bats diff --git a/.github/workflows/mirror-publish.yaml b/.github/workflows/mirror-publish.yaml new file mode 100644 index 00000000..b6e36dc6 --- /dev/null +++ b/.github/workflows/mirror-publish.yaml @@ -0,0 +1,327 @@ +# 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: the Helm charts, the installer, +# README / LICENSE / operator docs, the GitHub Pages chart index, and each +# GitHub release with its assets. 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 Helm Chart" completes successfully. NOT on +# `release: published`: that event fires when the release +# is created, BEFORE the chart workflow has attached the +# charts, installer and signed manifest and pushed the +# gh-pages index — a mirror cut then would copy a release +# with half its assets. The completed chart workflow is the +# moment every asset exists. head_branch of a release- +# triggered run is the tag (measured on v1.9.109 / -rc.1). +# 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 Helm Chart"] + 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: Guard the chart index (gh-pages) + # The mirror serves the Helm repository at the same Pages URL, so its + # gh-pages branch is a copy of this repo's. Guarded with the Pages + # allowlist (index + packages, nothing else) and the same forbidden + # lists. A missing gh-pages is not "nothing to do": the chart workflow + # just pushed to it, so its absence means this run is looking at the + # wrong repository. + run: | + set -uo pipefail + git fetch --depth 1 origin gh-pages || { echo "::error::could not fetch gh-pages — the chart index is missing, refusing to mirror"; exit 2; } + git worktree add --detach "$RUNNER_TEMP/pages-src" FETCH_HEAD || exit 2 + bash scripts/publish-guard.sh \ + --source "$RUNNER_TEMP/pages-src" \ + --include .publish-include-pages \ + --forbidden .publish-forbidden \ + --extra-forbidden "$RUNNER_TEMP/tenants.txt" \ + --out "$RUNNER_TEMP/stage-pages" | tee "$RUNNER_TEMP/guard-pages.log" + rc=${PIPESTATUS[0]} + { + echo "## Mirror publish — chart index (gh-pages)" + echo + echo '```' + cat "$RUNNER_TEMP/guard-pages.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: Push the chart index to the mirror's gh-pages + if: steps.plan.outputs.dry_run != 'true' + env: + MIRROR_TOKEN: ${{ steps.token.outputs.token }} + REPO: ${{ steps.target.outputs.repo }} + TAG: ${{ steps.plan.outputs.tag }} + run: | + set -euo pipefail + bash scripts/publish-mirror.sh tree --stage "$RUNNER_TEMP/stage-pages/tree" --repo "$REPO" --branch gh-pages --message "Chart index at $TAG" + + - 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 client $TAG." + echo + echo "Install with the one-liner in the README; the assets attached here are the" + echo "installer, the packaged Helm charts and the cosign-signed installer manifest." + echo "Verification recipe: docs/SUPPLY_CHAIN.md." + } >"$RUNNER_TEMP/notes.md" + args=(release --tag "$TAG" --repo "$REPO" --target "$SHA" --assets "$RUNNER_TEMP/stage/assets" --notes "$RUNNER_TEMP/notes.md") + [ "$PRERELEASE" != "true" ] || args+=(--prerelease) + bash scripts/publish-mirror.sh "${args[@]}" diff --git a/.publish-forbidden b/.publish-forbidden new file mode 100644 index 00000000..7d4b7dfd --- /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 00000000..46c27a3b --- /dev/null +++ b/.publish-include @@ -0,0 +1,29 @@ +# .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, and it +# refuses the excluded directories below even if the `!` lines were deleted. + +# The two Helm charts that `helm repo add tracebloc https://tracebloc.github.io/client` +# serves. Their unit-test suites and CI values never ship (see .helmignore). +client/** +!client/tests/** +!client/ci/** +ingestor/** + +# The installer: the two bootstraps plus every file they fetch and verify +# against scripts/manifest.sha256 (the sub-scripts live under scripts/lib/). +scripts/install.sh +scripts/install.ps1 +scripts/install-k8s.sh +scripts/install-k8s.ps1 +scripts/lib/** +scripts/manifest.sha256 + +# Front matter, and the operator docs README links to. One level only: +# docs/rfcs/ and docs/migration-tools/ stay home. +README.md +LICENSE +docs/*.md diff --git a/.publish-include-pages b/.publish-include-pages new file mode 100644 index 00000000..04c81f04 --- /dev/null +++ b/.publish-include-pages @@ -0,0 +1,7 @@ +# .publish-include-pages — what the mirror's GitHub Pages branch MAY carry. +# +# Read by scripts/publish-guard.sh with `--include` when the gh-pages branch is +# mirrored (it is what `helm repo add tracebloc https://tracebloc.github.io/client` +# reads). The branch holds the chart index and the packaged charts, nothing else. +index.yaml +*.tgz diff --git a/scripts/publish-guard.sh b/scripts/publish-guard.sh new file mode 100755 index 00000000..702f7306 --- /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 00000000..18f5e21e --- /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.bats b/scripts/tests/publish-guard.bats
new file mode 100644
index 00000000..b5c0d52f
--- /dev/null
+++ b/scripts/tests/publish-guard.bats
@@ -0,0 +1,476 @@
+#!/usr/bin/env bats
+# scripts/publish-guard.sh — stage the public deliverable and refuse anything
+# else. Every test drives the REAL script against a fixture git repository it
+# builds itself; the allowlists and forbidden lists the fixtures use are written
+# HERE, independently of the repo's own .publish-include / .publish-forbidden,
+# so the guard is never tested against its own copy of the rule. The repo's
+# real lists get their own section at the end, fed inputs this file writes.
+#
+# Three verdicts, and every test names the one it expects: 0 clean, 1 refused,
+# 2 could not tell. A mutation that reddens the guard must redden it for the
+# rule the test is named for, so refusals are matched on the guard's `[name]`
+# and the offending path/needle, not on the exit code alone.
+#
+# gitleaks is replaced by a PATH shim in every test but one, so the verdict
+# plumbing (clean / leak / crash / absent) is exercised hermetically; the last
+# gitleaks test runs the real binary when it is on PATH and skips visibly
+# otherwise.
+
+GUARD=""
+REPO=""
+SRC=""
+OUT=""
+SHIM=""
+
+setup() {
+  REPO="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)"
+  GUARD="$REPO/scripts/publish-guard.sh"
+  SRC="$BATS_TEST_TMPDIR/src"
+  OUT="$BATS_TEST_TMPDIR/out"
+  SHIM="$BATS_TEST_TMPDIR/shim"
+  mkdir -p "$SHIM"
+  # gitleaks shim: GL_MODE=clean|leak|crash. Prints its own marker so a test can
+  # tell the shim ran.
+  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"; echo "File: tree/README.md"; echo "Line: 1"; exit 9 ;;
+  crash) echo "panic: shim crash"; exit 1 ;;
+esac
+EOF
+  chmod +x "$SHIM/gitleaks"
+  export PUBLISH_GUARD_GITLEAKS="$SHIM/gitleaks"
+  mk_repo
+}
+
+# A fixture repository with the shapes the guards must tell apart: deliverable
+# files, a test suite inside a deliverable directory, source, build files.
+mk_repo() {
+  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 chart. Deployment help: support@tracebloc.io'
+  add_file LICENSE 'Apache-2.0'
+  add_file client/Chart.yaml 'apiVersion: v2'
+  add_file client/templates/deploy.yaml 'kind: Deployment'
+  add_file client/tests/x_test.yaml 'suite: x'
+  add_file notes/tests 'a FILE named tests, not a directory'
+  add_file scripts/install.sh '#!/usr/bin/env bash'
+  add_file scripts/lib/common.sh 'log() { :; }'
+  add_file scripts/tests/a.bats '@test "x" { :; }'
+  add_file other/scripts/tests/b.bats '@test "y" { :; }'
+  add_file Makefile 'all:'
+  add_file CLAUDE.md 'guidance'
+  add_file .github/workflows/ci.yml 'on: push'
+  add_file internal/main.go 'package main'
+  commit
+  write_include \
+    'client/**' \
+    '!client/tests/**' \
+    'scripts/install.sh' \
+    'scripts/lib/**' \
+    'notes/**' \
+    'README.md' \
+    'LICENSE'
+  write_forbidden
+}
+
+add_file() { # path content
+  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"; }
+# The fixture's forbidden list — a DIFFERENT list from the repo's, written here.
+write_forbidden() {
+  {
+    printf '[paths]\n'
+    printf '%s\n' 'tests/' 'scripts/tests/' 'Makefile' 'CLAUDE.md' '.github/' '*.go' '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"
+}
+# plant PATH LINE — append LINE to a fixture file, commit, and PROVE it landed
+# (an inert mutation and real coverage look identical in a log).
+plant() {
+  printf '%s\n' "$2" >>"$SRC/$1"
+  git -C "$SRC" add "$1" && commit
+  [ "$(grep -cF -- "$2" "$SRC/$1")" -eq 1 ] || return 1
+}
+guard() { run bash "$GUARD" --source "$SRC" --out "$OUT" "$@"; }
+staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
+
+# ── the clean case, and what "clean" is made of ───────────────────────────────
+
+@test "a clean fixture is staged, all four guards report, exit 0" {
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[allowlist] staged 7 of 14 tracked file(s)"* ]] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-paths] clean (7 pattern(s) against 7 staged path(s))"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] clean (4 needle(s), 1 allow token(s); 7 text file(s) scanned, 0 binary"* ]] || return 1
+  [[ "$output" == *"[gitleaks] clean"* ]] || return 1
+  [[ "$output" == *"publish-guard: OK — all 4 guards ran and passed"* ]] || return 1
+  # The staged tree is exactly the allowlisted set: nothing more, nothing less.
+  [ "$(staged | paste -sd' ' -)" = "LICENSE README.md client/Chart.yaml client/templates/deploy.yaml notes/tests scripts/install.sh scripts/lib/common.sh" ] || { staged; return 1; }
+  [ ! -e "$OUT/tree/Makefile" ] || return 1
+  [ ! -e "$OUT/tree/client/tests" ] || return 1
+  [ ! -e "$OUT/tree/internal" ] || return 1
+}
+
+@test "an untracked file matching the allowlist is not staged (tracked files only)" {
+  printf 'scratch\n' >"$SRC/client/untracked.yaml"
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ ! -e "$OUT/tree/client/untracked.yaml" ] || return 1
+}
+
+@test "every guard still runs and reports after an earlier one has refused" {
+  write_include 'client/**' 'README.md'     # drops the !client/tests/** line
+  guard
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"[forbidden-paths] REFUSED"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] clean"* ]] || return 1
+  [[ "$output" == *"[gitleaks] clean"* ]] || return 1
+  [[ "$output" == *"publish-guard: REFUSED — do not publish"* ]] || return 1
+}
+
+# ── guard 2: forbidden paths ──────────────────────────────────────────────────
+
+@test "mutation: dropping the !client/tests/** exclusion is caught by the tests/ path rule" {
+  write_include 'client/**' 'README.md'
+  guard
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [ -f "$OUT/tree/client/tests/x_test.yaml" ] || return 1   # the mutation landed: the file WAS staged
+  [[ "$output" == *"[forbidden-paths] REFUSED — forbidden path pattern 'tests/' matched:"* ]] || return 1
+  [[ "$output" == *"tree:client/tests/x_test.yaml"* ]] || return 1
+}
+
+@test "mutation: allowlisting Go source is caught by *.go, by file name anywhere in the tree" {
+  write_include 'client/**' '!client/tests/**' 'internal/**' 'README.md'
+  guard
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"forbidden path pattern '*.go' matched:"* ]] || return 1
+  [[ "$output" == *"tree:internal/main.go"* ]] || return 1
+}
+
+@test "mutation: allowlisting the Makefile and CLAUDE.md is caught by name" {
+  write_include 'Makefile' 'CLAUDE.md' 'README.md'
+  guard
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"forbidden path pattern 'Makefile' matched:"*"tree:Makefile"* ]] || return 1
+  [[ "$output" == *"forbidden path pattern 'CLAUDE.md' matched:"*"tree:CLAUDE.md"* ]] || return 1
+}
+
+@test "mutation: a workflow directory is caught by .github/ as a directory anywhere" {
+  write_include '.github/**' 'README.md'
+  guard
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"forbidden path pattern '.github/' matched:"*"tree:.github/workflows/ci.yml"* ]] || return 1
+}
+
+@test "a pattern with a slash is anchored to the staged root; one without matches any component" {
+  write_include 'scripts/tests/**' 'other/**' 'README.md'
+  printf '[paths]\nscripts/tests/\n[strings]\nbackend#\n' >"$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"forbidden path pattern 'scripts/tests/' matched:"* ]] || return 1
+  [[ "$output" == *"tree:scripts/tests/a.bats"* ]] || return 1
+  [[ "$output" != *"tree:other/scripts/tests/b.bats"* ]] || return 1   # anchored: not this one
+  # The unanchored form reaches it.
+  rm -rf "$OUT"
+  printf '[paths]\ntests/\n[strings]\nbackend#\n' >"$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"tree:other/scripts/tests/b.bats"* ]] || return 1
+}
+
+@test "a trailing slash means 'as a directory': a FILE named tests is not a tests/ finding" {
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ -f "$OUT/tree/notes/tests" ] || return 1
+}
+
+@test "a release asset named like a credential file is refused by the path rule" {
+  mkdir -p "$BATS_TEST_TMPDIR/assets"
+  printf 'apiVersion: v1\n' >"$BATS_TEST_TMPDIR/assets/kubeconfig.yaml"
+  guard --assets "$BATS_TEST_TMPDIR/assets"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"forbidden path pattern 'kubeconfig*' matched:"*"assets:kubeconfig.yaml"* ]] || return 1
+}
+
+@test "no [paths] entries is could-not-tell, not clean" {
+  printf '[strings]\nbackend#\n' >"$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[forbidden-paths] COULD NOT TELL — '"*"' has no [paths] entries"* ]] || return 1
+}
+
+# ── guard 3: forbidden strings ────────────────────────────────────────────────
+
+@test "mutation: an internal tracker reference in a staged file is refused, file and line named" {
+  plant README.md 'see backend#1234 for the rationale'
+  guard
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] REFUSED — needle 'backend#' found in 1 staged line(s):"* ]] || return 1
+  [[ "$output" == *"    tree/README.md:2"* ]] || return 1
+  # The matched text itself is never echoed.
+  [[ "$output" != *"for the rationale"* ]] || return 1
+}
+
+@test "mutation: a non-production hostname in a staged file is refused" {
+  plant client/Chart.yaml '# points at dev-api.tracebloc.io'
+  guard
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"needle 'dev-api\.tracebloc\.io' found in 1 staged line(s):"*"tree/client/Chart.yaml:2"* ]] || return 1
+}
+
+@test "needles match case-insensitively" {
+  plant README.md 'BACKEND#7'
+  guard
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"needle 'backend#' found in 1 staged line(s)"* ]] || return 1
+}
+
+@test "an [allow] token spares a line only when it was the whole reason the needle hit" {
+  # The fixture README already carries support@tracebloc.io → clean.
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  # A personal mailbox on the same line as the allowed one is still refused.
+  rm -rf "$OUT"
+  plant README.md 'or write to someone@tracebloc.io / support@tracebloc.io'
+  guard
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"needle '[A-Za-z0-9._%+-]+@tracebloc\.io' found in 1 staged line(s):"*"tree/README.md:2"* ]] || return 1
+}
+
+@test "mutation: a private needle supplied with --extra-forbidden is enforced" {
+  printf '# private list\nplanted-tenant\n' >"$BATS_TEST_TMPDIR/tenants.txt"
+  plant client/templates/deploy.yaml '# for Planted-Tenant only'
+  guard --extra-forbidden "$BATS_TEST_TMPDIR/tenants.txt"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"needle 'planted-tenant' found in 1 staged line(s):"*"tree/client/templates/deploy.yaml:2"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] 1 hit(s) across 5 needle(s)"* ]] || return 1
+}
+
+@test "an empty --extra-forbidden list is could-not-tell: the private needles were not supplied" {
+  printf '# nothing here\n\n' >"$BATS_TEST_TMPDIR/tenants.txt"
+  guard --extra-forbidden "$BATS_TEST_TMPDIR/tenants.txt"
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] COULD NOT TELL — extra forbidden list '"*"tenants.txt' is empty"* ]] || return 1
+}
+
+@test "a missing --extra-forbidden list is could-not-tell" {
+  guard --extra-forbidden "$BATS_TEST_TMPDIR/absent.txt"
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[forbidden-strings] COULD NOT TELL — extra forbidden list '"*"absent.txt' is missing or unreadable"* ]] || return 1
+}
+
+@test "an internal reference inside a release asset is refused with the asset named" {
+  mkdir -p "$BATS_TEST_TMPDIR/assets"
+  printf '#!/bin/sh\n# see backend#42\n' >"$BATS_TEST_TMPDIR/assets/install.sh"
+  guard --assets "$BATS_TEST_TMPDIR/assets"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[assets] staged 1 release asset(s):"*"    install.sh"* ]] || return 1
+  [[ "$output" == *"needle 'backend#' found in 1 staged line(s):"*"assets/install.sh:2"* ]] || return 1
+}
+
+@test "a binary asset is opaque to the string scan and counted as such" {
+  mkdir -p "$BATS_TEST_TMPDIR/assets"
+  printf 'ELF\000\000backend#1\000' >"$BATS_TEST_TMPDIR/assets/tracebloc-linux-amd64"
+  guard --assets "$BATS_TEST_TMPDIR/assets"
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"7 text file(s) scanned, 1 binary file(s) opaque to this scan"* ]] || return 1
+}
+
+@test "no [strings] entries is could-not-tell, not clean" {
+  printf '[paths]\ntests/\n' >"$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[forbidden-strings] COULD NOT TELL — '"*"' has no [strings] entries"* ]] || return 1
+}
+
+@test "a missing forbidden list is could-not-tell for both scans that read it" {
+  rm "$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[forbidden-paths] COULD NOT TELL — forbidden list '"*"' is missing or unreadable"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] COULD NOT TELL — forbidden list '"*"' is missing or unreadable"* ]] || return 1
+}
+
+# ── guard 1: allowlist fail-closed cases ──────────────────────────────────────
+
+@test "an allowlist with no include entries is could-not-tell" {
+  printf '# only comments\n\n' >"$SRC/.publish-include"
+  guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[allowlist] COULD NOT TELL — allowlist '"*"' lists no include entries"* ]] || return 1
+}
+
+@test "a missing allowlist is could-not-tell" {
+  rm "$SRC/.publish-include"
+  guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[allowlist] COULD NOT TELL — allowlist '"*"' is missing or unreadable"* ]] || return 1
+}
+
+@test "an allowlist that matches nothing is could-not-tell (a mirror with nothing in it)" {
+  write_include 'nothing-here/**'
+  guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[allowlist] COULD NOT TELL — the allowlist matched none of the 14 tracked files"* ]] || return 1
+}
+
+@test "a symlink in the allowlisted set is could-not-tell" {
+  ln -s ../Makefile "$SRC/client/link"
+  git -C "$SRC" add client/link && commit
+  guard
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[allowlist] COULD NOT TELL — 'client/link' is a symlink"* ]] || return 1
+}
+
+@test "a non-empty --out is could-not-tell" {
+  mkdir -p "$OUT" && printf 'stale\n' >"$OUT/stale.txt"
+  guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"COULD NOT TELL — --out '"*"' is not empty"* ]] || return 1
+}
+
+@test "a source that is not a git work tree is could-not-tell" {
+  mkdir -p "$BATS_TEST_TMPDIR/plain"
+  cp "$SRC/.publish-include" "$SRC/.publish-forbidden" "$BATS_TEST_TMPDIR/plain/"
+  run bash "$GUARD" --source "$BATS_TEST_TMPDIR/plain" --out "$OUT"
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[allowlist] COULD NOT TELL — git ls-files failed"* ]] || return 1
+}
+
+@test "an --assets directory with no files is could-not-tell" {
+  mkdir -p "$BATS_TEST_TMPDIR/assets"
+  guard --assets "$BATS_TEST_TMPDIR/assets"
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"COULD NOT TELL — --assets '"*"' holds no files"* ]] || return 1
+}
+
+# ── guard 4: gitleaks plumbing, then the real thing ───────────────────────────
+
+@test "a missing scanner is could-not-tell, never clean" {
+  PUBLISH_GUARD_GITLEAKS="$BATS_TEST_TMPDIR/no-such-gitleaks" guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[gitleaks] COULD NOT TELL — scanner '"*"no-such-gitleaks' is not on PATH"* ]] || return 1
+  [[ "$output" == *"publish-guard: COULD NOT TELL — do not publish"* ]] || return 1
+}
+
+@test "a scanner that finds a secret refuses, and its redacted report is shown" {
+  GL_MODE=leak guard
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[gitleaks] REFUSED — secrets detected in the staged tree:"* ]] || return 1
+  [[ "$output" == *"Finding: REDACTED"* ]] || return 1
+}
+
+@test "a scanner that crashes is could-not-tell (its exit code is not a verdict)" {
+  GL_MODE=crash guard
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"[gitleaks] COULD NOT TELL — scanner exited 1"* ]] || return 1
+}
+
+@test "the scanner is pointed at the staged tree, with --no-git and --redact" {
+  # The scanner's own output is shown on a refusal, which is when its argv is
+  # visible to this test. The guard resolves --out to an absolute path, so only
+  # the tail of the --source value is compared.
+  GL_MODE=leak guard
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"shim gitleaks ran: detect --no-git --redact --no-banner --exit-code 9 --source "*"/out"* ]] || { echo "$output"; return 1; }
+}
+
+@test "real gitleaks: a planted access-key-shaped string in a staged file is refused" {
+  command -v gitleaks >/dev/null 2>&1 || skip "gitleaks not on PATH (the publish workflow installs it; run locally with gitleaks installed)"
+  unset PUBLISH_GUARD_GITLEAKS
+  # Built at run time, so this file never carries a key-shaped literal.
+  local key
+  key="AKIA$(LC_ALL=C tr -dc 'A-Z2-7' "$STAGE/README.md"
+  printf 'license\n' >"$STAGE/LICENSE"
+  printf 'doc\n' >"$STAGE/docs/a.md"
+  git init -q --bare "$BARE"
+  # gh shim: records every argv line to GH_LOG; `release view` answers per
+  # GH_VIEW_RC / GH_VIEW_ERR; everything else succeeds.
+  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="$BATS_TEST_TMPDIR/gh.log"
+  : >"$GH_LOG"
+}
+
+pub() { run bash "$PUB" "$@"; }
+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 "$1" | sort | paste -sd' ' -; }
+
+# ── target ────────────────────────────────────────────────────────────────────
+
+@test "target: no mirror configured is refused — there is no default" {
+  pub target --mirror '' --source-repo tracebloc/client
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"REFUSED — no mirror repository is configured (MIRROR_REPO is unset)"* ]] || return 1
+}
+
+@test "target: the source repository itself is refused, case-insensitively" {
+  pub target --mirror client --source-repo tracebloc/client
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"REFUSED — mirror 'tracebloc/client' is this repository"* ]] || return 1
+  pub target --mirror Client --source-repo tracebloc/client
+  [ "$status" -eq 1 ] || return 1
+}
+
+@test "target: a name with characters a repository cannot have, or an OWNER/NAME, is refused" {
+  pub target --mirror 'cli mirror' --source-repo tracebloc/client
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"contains characters a repository name cannot"* ]] || return 1
+  pub target --mirror 'other/cli' --source-repo tracebloc/client
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"must be a bare repository name"* ]] || return 1
+}
+
+@test "target: a valid mirror prints OWNER/NAME in the source's organisation" {
+  pub target --mirror client-public --source-repo tracebloc/client
+  [ "$status" -eq 0 ] || return 1
+  [ "$output" = "tracebloc/client-public" ] || return 1
+}
+
+@test "target: a missing --source-repo is could-not-tell (the self-publish check needs it)" {
+  pub target --mirror client-public
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"COULD NOT TELL — target: --source-repo is required"* ]] || return 1
+}
+
+# ── tree ──────────────────────────────────────────────────────────────────────
+
+@test "tree: the first publish starts the branch and the mirror holds exactly the stage" {
+  tree
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == pushed\ [0-9a-f]* ]] || return 1
+  [ "$(mirror_files main)" = "LICENSE README.md docs/a.md" ] || return 1
+  [ "$(git -C "$BARE" rev-list --count main)" -eq 1 ] || return 1
+}
+
+@test "tree: an identical stage is a no-op, reported as unchanged" {
+  tree; [ "$status" -eq 0 ] || return 1
+  tree
+  [ "$status" -eq 0 ] || return 1
+  [[ "$output" == unchanged\ [0-9a-f]* ]] || return 1
+  [ "$(git -C "$BARE" rev-list --count main)" -eq 1 ] || return 1
+}
+
+@test "tree: a later publish replaces the content — removed files vanish, history is appended" {
+  tree; [ "$status" -eq 0 ] || return 1
+  local first
+  first="$(git -C "$BARE" rev-parse main)"
+  rm "$STAGE/docs/a.md"; printf 'new\n' >"$STAGE/CHANGES.md"
+  tree
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(mirror_files main)" = "CHANGES.md LICENSE README.md" ] || return 1
+  [ "$(git -C "$BARE" rev-list --count main)" -eq 2 ] || return 1
+  [ "$(git -C "$BARE" rev-parse main^)" = "$first" ] || return 1   # appended, not rewritten
+}
+
+@test "tree: a mirror branch with prior content not from this pipeline is replaced on top, not force-pushed over" {
+  local seed="$BATS_TEST_TMPDIR/seed"
+  git clone -q "file://$BARE" "$seed" 2>/dev/null
+  printf 'old\n' >"$seed/old.txt"
+  git -C "$seed" -c user.name=t -c user.email=t@example.invalid add old.txt
+  git -C "$seed" -c user.name=t -c user.email=t@example.invalid commit -q -m seed
+  git -C "$seed" push -q origin HEAD:refs/heads/main
+  local seeded
+  seeded="$(git -C "$BARE" rev-parse main)"
+  tree
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(mirror_files main)" = "LICENSE README.md docs/a.md" ] || return 1
+  [ "$(git -C "$BARE" rev-parse main^)" = "$seeded" ] || return 1
+}
+
+@test "tree: a remote that does not answer is could-not-tell, not a fresh start" {
+  pub tree --stage "$STAGE" --repo tracebloc/mirror --branch main --message m --remote "file://$BATS_TEST_TMPDIR/no-such.git"
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"COULD NOT TELL — tree: the mirror remote did not answer"* ]] || return 1
+}
+
+@test "tree: an empty stage, or one that is a checkout, is could-not-tell" {
+  rm -r "$STAGE"; mkdir -p "$STAGE"
+  tree
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"holds no files"* ]] || return 1
+  mkdir -p "$STAGE/.git"; printf 'x\n' >"$STAGE/README.md"
+  tree
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"contains a .git entry"* ]] || return 1
+}
+
+@test "tree: the script never forces a push" {
+  run grep -nE -- '--force|\+refs/|-f[[:space:]]' "$PUB"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+}
+
+# ── release ───────────────────────────────────────────────────────────────────
+
+release() {
+  PATH="$SHIM:$PATH" pub release --tag v1.0.0 --repo tracebloc/mirror --target 0123456789abcdef0123456789abcdef01234567 --assets "$STAGE" --notes "$BATS_TEST_TMPDIR/notes.md" "$@"
+}
+
+@test "release: creates the tag at the target with every asset, no generated notes" {
+  printf 'Release notes\n' >"$BATS_TEST_TMPDIR/notes.md"
+  release
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == "released v1.0.0 on tracebloc/mirror at 0123456789abcdef0123456789abcdef01234567 with 2 asset(s)" ]] || return 1
+  grep -q '^release view v1.0.0 --repo tracebloc/mirror$' "$GH_LOG" || return 1
+  local create
+  create="$(grep '^release create' "$GH_LOG")"
+  [[ "$create" == "release create v1.0.0 --repo tracebloc/mirror --target 0123456789abcdef0123456789abcdef01234567 --title v1.0.0 --notes-file $BATS_TEST_TMPDIR/notes.md $STAGE/LICENSE $STAGE/README.md" ]] || { echo "$create"; return 1; }
+  [[ "$create" != *"--generate-notes"* ]] || return 1
+  [[ "$create" != *"--prerelease"* ]] || return 1
+}
+
+@test "release: --prerelease is passed through" {
+  printf 'notes\n' >"$BATS_TEST_TMPDIR/notes.md"
+  release --prerelease
+  [ "$status" -eq 0 ] || return 1
+  grep -q '^release create .* --prerelease ' "$GH_LOG" || return 1
+}
+
+@test "release: a tag that already exists on the mirror is refused, never overwritten" {
+  printf 'notes\n' >"$BATS_TEST_TMPDIR/notes.md"
+  GH_VIEW_RC=0 release
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"REFUSED — release: 'v1.0.0' already exists on 'tracebloc/mirror'"* ]] || return 1
+  run grep -c '^release create' "$GH_LOG"
+  [ "$output" = "0" ] || return 1
+}
+
+@test "release: a view failure that is not 'not found' is could-not-tell" {
+  printf 'notes\n' >"$BATS_TEST_TMPDIR/notes.md"
+  GH_VIEW_RC=1 GH_VIEW_ERR='HTTP 401: Bad credentials' release
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"COULD NOT TELL — release: could not read releases of 'tracebloc/mirror'"* ]] || return 1
+  run grep -c '^release create' "$GH_LOG"
+  [ "$output" = "0" ] || return 1
+}
+
+@test "release: a malformed tag is refused; a short sha, empty notes or no assets are could-not-tell" {
+  printf 'notes\n' >"$BATS_TEST_TMPDIR/notes.md"
+  PATH="$SHIM:$PATH" pub release --tag main --repo tracebloc/mirror --target 0123456789abcdef0123456789abcdef01234567 --assets "$STAGE" --notes "$BATS_TEST_TMPDIR/notes.md"
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"'main' is not a release tag"* ]] || return 1
+  PATH="$SHIM:$PATH" pub release --tag v1.0.0 --repo tracebloc/mirror --target abc123 --assets "$STAGE" --notes "$BATS_TEST_TMPDIR/notes.md"
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"is not a full commit sha"* ]] || return 1
+  : >"$BATS_TEST_TMPDIR/empty.md"
+  PATH="$SHIM:$PATH" pub release --tag v1.0.0 --repo tracebloc/mirror --target 0123456789abcdef0123456789abcdef01234567 --assets "$STAGE" --notes "$BATS_TEST_TMPDIR/empty.md"
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"notes file"*"is missing or empty"* ]] || return 1
+  mkdir -p "$BATS_TEST_TMPDIR/none"
+  PATH="$SHIM:$PATH" pub release --tag v1.0.0 --repo tracebloc/mirror --target 0123456789abcdef0123456789abcdef01234567 --assets "$BATS_TEST_TMPDIR/none" --notes "$BATS_TEST_TMPDIR/notes.md"
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == *"holds no files"* ]] || return 1
+  run grep -c '^release create' "$GH_LOG"
+  [ "$output" = "0" ] || return 1
+}

From 92399d88b8a42b0437adb9790ce148ca1a26821b Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 16:18:32 +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 825 hits, 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: 39 -> 47. 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.yaml |  41 ++++-
 .publish-forbidden                    |  62 ++++---
 scripts/publish-guard.sh              | 175 ++++++++++++++-----
 scripts/tests/publish-guard.bats      | 234 +++++++++++++++++++++-----
 4 files changed, 392 insertions(+), 120 deletions(-)

diff --git a/.github/workflows/mirror-publish.yaml b/.github/workflows/mirror-publish.yaml
index b6e36dc6..e542c4ea 100644
--- a/.github/workflows/mirror-publish.yaml
+++ b/.github/workflows/mirror-publish.yaml
@@ -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]}
           {
@@ -203,16 +228,16 @@ jobs:
         # lists. A missing gh-pages is not "nothing to do": the chart workflow
         # just pushed to it, so its absence means this run is looking at the
         # wrong repository.
+        env:
+          STRICT: ${{ steps.plan.outputs.strict }}
         run: |
           set -uo pipefail
           git fetch --depth 1 origin gh-pages || { echo "::error::could not fetch gh-pages — the chart index is missing, refusing to mirror"; exit 2; }
           git worktree add --detach "$RUNNER_TEMP/pages-src" FETCH_HEAD || exit 2
-          bash scripts/publish-guard.sh \
-            --source "$RUNNER_TEMP/pages-src" \
-            --include .publish-include-pages \
-            --forbidden .publish-forbidden \
-            --extra-forbidden "$RUNNER_TEMP/tenants.txt" \
-            --out "$RUNNER_TEMP/stage-pages" | tee "$RUNNER_TEMP/guard-pages.log"
+          args=(--source "$RUNNER_TEMP/pages-src" --include .publish-include-pages --forbidden .publish-forbidden
+                --extra-forbidden "$RUNNER_TEMP/tenants.txt" --out "$RUNNER_TEMP/stage-pages")
+          [ "$STRICT" != "true" ] || args+=(--strict)
+          bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-pages.log"
           rc=${PIPESTATUS[0]}
           {
             echo "## Mirror publish — chart index (gh-pages)"
diff --git a/.publish-forbidden b/.publish-forbidden
index 7d4b7dfd..41f4d35c 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/publish-guard.sh b/scripts/publish-guard.sh
index 702f7306..ff7349e7 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.bats b/scripts/tests/publish-guard.bats
index b5c0d52f..b4d59cf2 100644
--- a/scripts/tests/publish-guard.bats
+++ b/scripts/tests/publish-guard.bats
@@ -87,16 +87,23 @@ add_file() { # path content
 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"; }
 # The fixture's forbidden list — a DIFFERENT list from the repo's, written here.
+# Two string tiers: a mailbox and an ARN refuse; a tracker reference, an RFC
+# identifier and a non-production host are reported.
 write_forbidden() {
   {
     printf '[paths]\n'
     printf '%s\n' 'tests/' 'scripts/tests/' 'Makefile' 'CLAUDE.md' '.github/' '*.go' '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"
 }
+# A forbidden list with only a [paths] section and the refuse tier below, for
+# the tests that exercise path matching alone.
+paths_only_forbidden() { printf '[paths]\n%s\n[strings-refuse]\narn:aws:\n' "$1" >"$SRC/.publish-forbidden"; }
 # plant PATH LINE — append LINE to a fixture file, commit, and PROVE it landed
 # (an inert mutation and real coverage look identical in a log).
 plant() {
@@ -114,7 +121,7 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
   [ "$status" -eq 0 ] || { echo "$output"; return 1; }
   [[ "$output" == *"[allowlist] staged 7 of 14 tracked file(s)"* ]] || { echo "$output"; return 1; }
   [[ "$output" == *"[forbidden-paths] clean (7 pattern(s) against 7 staged path(s))"* ]] || return 1
-  [[ "$output" == *"[forbidden-strings] clean (4 needle(s), 1 allow token(s); 7 text file(s) scanned, 0 binary"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] clean (2 refuse + 3 report needle(s), 1 allow token(s); 7 text file(s) scanned, 0 binary"* ]] || return 1
   [[ "$output" == *"[gitleaks] clean"* ]] || return 1
   [[ "$output" == *"publish-guard: OK — all 4 guards ran and passed"* ]] || return 1
   # The staged tree is exactly the allowlisted set: nothing more, nothing less.
@@ -177,7 +184,7 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
 
 @test "a pattern with a slash is anchored to the staged root; one without matches any component" {
   write_include 'scripts/tests/**' 'other/**' 'README.md'
-  printf '[paths]\nscripts/tests/\n[strings]\nbackend#\n' >"$SRC/.publish-forbidden"
+  paths_only_forbidden 'scripts/tests/'
   guard
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }
   [[ "$output" == *"forbidden path pattern 'scripts/tests/' matched:"* ]] || return 1
@@ -185,7 +192,7 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
   [[ "$output" != *"tree:other/scripts/tests/b.bats"* ]] || return 1   # anchored: not this one
   # The unanchored form reaches it.
   rm -rf "$OUT"
-  printf '[paths]\ntests/\n[strings]\nbackend#\n' >"$SRC/.publish-forbidden"
+  paths_only_forbidden 'tests/'
   guard
   [ "$status" -eq 1 ] || return 1
   [[ "$output" == *"tree:other/scripts/tests/b.bats"* ]] || return 1
@@ -206,36 +213,30 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
 }
 
 @test "no [paths] entries is could-not-tell, not clean" {
-  printf '[strings]\nbackend#\n' >"$SRC/.publish-forbidden"
+  printf '[strings-refuse]\narn:aws:\n' >"$SRC/.publish-forbidden"
   guard
   [ "$status" -eq 2 ] || return 1
   [[ "$output" == *"[forbidden-paths] COULD NOT TELL — '"*"' has no [paths] entries"* ]] || return 1
 }
 
-# ── guard 3: forbidden strings ────────────────────────────────────────────────
+# ── guard 3: forbidden strings — the refuse tier ──────────────────────────────
 
-@test "mutation: an internal tracker reference in a staged file is refused, file and line named" {
-  plant README.md 'see backend#1234 for the rationale'
+@test "mutation: a refuse-tier needle in a staged file is refused, tier, file and line named" {
+  plant README.md 'role arn:aws:iam::000000000000:role/planted'
   guard
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }
-  [[ "$output" == *"[forbidden-strings] REFUSED — needle 'backend#' found in 1 staged line(s):"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] REFUSED — [strings-refuse] needle 'arn:aws:' found in 1 staged line(s):"* ]] || return 1
   [[ "$output" == *"    tree/README.md:2"* ]] || return 1
   # The matched text itself is never echoed.
-  [[ "$output" != *"for the rationale"* ]] || return 1
-}
-
-@test "mutation: a non-production hostname in a staged file is refused" {
-  plant client/Chart.yaml '# points at dev-api.tracebloc.io'
-  guard
-  [ "$status" -eq 1 ] || return 1
-  [[ "$output" == *"needle 'dev-api\.tracebloc\.io' found in 1 staged line(s):"*"tree/client/Chart.yaml:2"* ]] || return 1
+  [[ "$output" != *"role/planted"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] 1 refuse-tier hit(s), 0 report-tier hit(s) counted"* ]] || return 1
 }
 
 @test "needles match case-insensitively" {
-  plant README.md 'BACKEND#7'
+  plant README.md 'ARN:AWS:s3:::planted'
   guard
   [ "$status" -eq 1 ] || return 1
-  [[ "$output" == *"needle 'backend#' found in 1 staged line(s)"* ]] || return 1
+  [[ "$output" == *"[strings-refuse] needle 'arn:aws:' found in 1 staged line(s)"* ]] || return 1
 }
 
 @test "an [allow] token spares a line only when it was the whole reason the needle hit" {
@@ -247,16 +248,16 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
   plant README.md 'or write to someone@tracebloc.io / support@tracebloc.io'
   guard
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }
-  [[ "$output" == *"needle '[A-Za-z0-9._%+-]+@tracebloc\.io' found in 1 staged line(s):"*"tree/README.md:2"* ]] || return 1
+  [[ "$output" == *"[strings-refuse] needle '[A-Za-z0-9._%+-]+@tracebloc\.io' found in 1 staged line(s):"*"tree/README.md:2"* ]] || return 1
 }
 
-@test "mutation: a private needle supplied with --extra-forbidden is enforced" {
+@test "mutation: a private needle supplied with --extra-forbidden joins the refuse tier" {
   printf '# private list\nplanted-tenant\n' >"$BATS_TEST_TMPDIR/tenants.txt"
   plant client/templates/deploy.yaml '# for Planted-Tenant only'
   guard --extra-forbidden "$BATS_TEST_TMPDIR/tenants.txt"
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }
-  [[ "$output" == *"needle 'planted-tenant' found in 1 staged line(s):"*"tree/client/templates/deploy.yaml:2"* ]] || return 1
-  [[ "$output" == *"[forbidden-strings] 1 hit(s) across 5 needle(s)"* ]] || return 1
+  [[ "$output" == *"[strings-refuse] needle 'planted-tenant' found in 1 staged line(s):"*"tree/client/templates/deploy.yaml:2"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] 1 refuse-tier hit(s), 0 report-tier hit(s) counted (3 refuse + 3 report needle(s)"* ]] || return 1
 }
 
 @test "an empty --extra-forbidden list is could-not-tell: the private needles were not supplied" {
@@ -272,28 +273,144 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
   [[ "$output" == *"[forbidden-strings] COULD NOT TELL — extra forbidden list '"*"absent.txt' is missing or unreadable"* ]] || return 1
 }
 
-@test "an internal reference inside a release asset is refused with the asset named" {
+@test "a refuse-tier needle inside a release asset is refused with the asset named" {
   mkdir -p "$BATS_TEST_TMPDIR/assets"
-  printf '#!/bin/sh\n# see backend#42\n' >"$BATS_TEST_TMPDIR/assets/install.sh"
+  printf '#!/bin/sh\n# bucket arn:aws:s3:::planted\n' >"$BATS_TEST_TMPDIR/assets/install.sh"
   guard --assets "$BATS_TEST_TMPDIR/assets"
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }
   [[ "$output" == *"[assets] staged 1 release asset(s):"*"    install.sh"* ]] || return 1
-  [[ "$output" == *"needle 'backend#' found in 1 staged line(s):"*"assets/install.sh:2"* ]] || return 1
+  [[ "$output" == *"[strings-refuse] needle 'arn:aws:' found in 1 staged line(s):"*"assets/install.sh:2"* ]] || return 1
 }
 
 @test "a binary asset is opaque to the string scan and counted as such" {
   mkdir -p "$BATS_TEST_TMPDIR/assets"
-  printf 'ELF\000\000backend#1\000' >"$BATS_TEST_TMPDIR/assets/tracebloc-linux-amd64"
+  printf 'ELF\000\000arn:aws:x\000' >"$BATS_TEST_TMPDIR/assets/tracebloc-linux-amd64"
   guard --assets "$BATS_TEST_TMPDIR/assets"
   [ "$status" -eq 0 ] || { echo "$output"; return 1; }
   [[ "$output" == *"7 text file(s) scanned, 1 binary file(s) opaque to this scan"* ]] || return 1
 }
 
-@test "no [strings] entries is could-not-tell, not clean" {
-  printf '[paths]\ntests/\n' >"$SRC/.publish-forbidden"
+# ── guard 3: forbidden strings — the report tier and --strict ─────────────────
+
+@test "a report-tier hit alone is counted, not refused: exit 0, per-needle total, most-hit files" {
+  plant README.md 'see backend#1234 for the rationale'
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] [strings-report] needle 'backend#' found in 1 staged line(s) — counted, not refused (--strict refuses)"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] [strings-report] 1 hit(s) in 1 file(s); most-hit files:"* ]] || return 1
+  [[ "$output" == *"         1  tree/README.md"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] 0 refuse-tier hit(s), 1 report-tier hit(s) counted"* ]] || return 1
+  [[ "$output" != *"REFUSED"* ]] || return 1
+  [[ "$output" == *"publish-guard: OK — all 4 guards ran and passed"* ]] || return 1
+  # The matched text itself is never echoed; the full location list is in the report.
+  [[ "$output" != *"for the rationale"* ]] || return 1
+  grep -qF "[strings-report] needle 'backend#':" "$OUT/publish-guard-report.txt" || return 1
+  grep -qF "tree/README.md:2" "$OUT/publish-guard-report.txt" || return 1
+}
+
+@test "mutation: the same report-tier hit under --strict is refused, tier named" {
+  plant README.md 'see backend#1234 for the rationale'
+  guard --strict
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] REFUSED — [strings-report (strict)] needle 'backend#' found in 1 staged line(s):"* ]] || return 1
+  [[ "$output" == *"    tree/README.md:2"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] 0 refuse-tier hit(s), 1 report-tier hit(s) refused under --strict"* ]] || return 1
+  [[ "$output" == *"publish-guard: REFUSED — do not publish"* ]] || return 1
+}
+
+@test "--strict with no report-tier hit is still clean" {
+  guard --strict
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] clean (2 refuse + 3 report needle(s)"* ]] || return 1
+}
+
+@test "a non-production hostname is report-tier: counted, and refused under --strict" {
+  plant client/Chart.yaml '# points at dev-api.tracebloc.io'
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[strings-report] needle 'dev-api\.tracebloc\.io' found in 1 staged line(s) — counted, not refused"* ]] || return 1
+  rm -rf "$OUT"
+  guard --strict
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == *"REFUSED — [strings-report (strict)] needle 'dev-api\.tracebloc\.io' found in 1 staged line(s):"*"tree/client/Chart.yaml:2"* ]] || return 1
+}
+
+@test "the most-hit table sums every report-tier needle per file, largest first, ten rows at most" {
+  plant README.md 'backend#1 and RFC-0001 on one line'
+  plant README.md 'backend#2 on another'
+  plant client/Chart.yaml '# backend#3'
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[strings-report] needle 'backend#' found in 3 staged line(s)"* ]] || return 1
+  [[ "$output" == *"[strings-report] needle 'RFC-0' found in 1 staged line(s)"* ]] || return 1
+  # 3 + 1 hits, 2 files; README's two lines (three hits) outrank Chart.yaml's one.
+  [[ "$output" == *"[strings-report] 4 hit(s) in 2 file(s); most-hit files:"*"         3  tree/README.md"*"         1  tree/client/Chart.yaml"* ]] || { echo "$output"; return 1; }
+  # Eleven files, one hit each: the table stops at ten.
+  rm -rf "$OUT"
+  local i
+  for i in 01 02 03 04 05 06 07 08 09 10 11; do add_file "notes/n$i.md" "ref backend#$i"; done
+  commit
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[strings-report] 15 hit(s) in 13 file(s); most-hit files:"* ]] || { echo "$output"; return 1; }
+  [ "$(printf '%s\n' "$output" | grep -cE '^ +[0-9]+  (tree|assets)/')" -eq 10 ] || { echo "$output"; return 1; }
+}
+
+@test "a refuse-tier hit and a report-tier hit in one run: refused, and the report tier still counted" {
+  plant README.md 'arn:aws:iam::000000000000:root — see backend#9'
+  guard
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"REFUSED — [strings-refuse] needle 'arn:aws:' found in 1 staged line(s):"* ]] || return 1
+  [[ "$output" == *"[strings-report] needle 'backend#' found in 1 staged line(s) — counted, not refused"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] 1 refuse-tier hit(s), 1 report-tier hit(s) counted"* ]] || return 1
+}
+
+# ── guard 3: the forbidden list itself ────────────────────────────────────────
+
+@test "no [strings-refuse] entries is could-not-tell, not clean (a guard with nothing to refuse)" {
+  printf '[paths]\ntests/\n[strings-report]\nbackend#\n' >"$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] COULD NOT TELL — '"*"' has no [strings-refuse] entries — a guard with nothing to refuse is misconfigured"* ]] || return 1
+  # A present-but-empty section is the same finding.
+  rm -rf "$OUT"
+  printf '[paths]\ntests/\n[strings-refuse]\n# none yet\n[strings-report]\nbackend#\n' >"$SRC/.publish-forbidden"
   guard
   [ "$status" -eq 2 ] || return 1
-  [[ "$output" == *"[forbidden-strings] COULD NOT TELL — '"*"' has no [strings] entries"* ]] || return 1
+  [[ "$output" == *"has no [strings-refuse] entries"* ]] || return 1
+}
+
+@test "an empty [strings-refuse] is judged before the private needles join it" {
+  printf '[paths]\ntests/\n[strings-report]\nbackend#\n' >"$SRC/.publish-forbidden"
+  printf 'planted-tenant\n' >"$BATS_TEST_TMPDIR/tenants.txt"
+  guard --extra-forbidden "$BATS_TEST_TMPDIR/tenants.txt"
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"has no [strings-refuse] entries"* ]] || return 1
+}
+
+@test "a needle listed in both string tiers is could-not-tell, the duplicate named" {
+  printf '[paths]\ntests/\n[strings-refuse]\narn:aws:\nbackend#\n[strings-report]\nbackend#\nRFC-0\n' >"$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] COULD NOT TELL — '"*"' lists needle 'backend#' in both [strings-refuse] and [strings-report] — a needle has one tier"* ]] || return 1
+}
+
+@test "an unknown section header is could-not-tell for both scans that read the list" {
+  # The retired name is the likeliest misspelling; nothing under it may be read
+  # as a rule of the section before it.
+  printf '[paths]\ntests/\n[strings]\narn:aws:\n' >"$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-paths] COULD NOT TELL — '"*"' has an unknown section [strings] — the guard reads only [paths] [strings-refuse] [strings-report] [allow]"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] COULD NOT TELL — '"*"' has an unknown section [strings]"* ]] || return 1
+  # A header with a space or a case slip is a header too, refused by name rather
+  # than read as a needle of the section before it.
+  rm -rf "$OUT"
+  printf '[paths]\ntests/\n[strings-refuse]\narn:aws:\n[strings report]\nbackend#\n' >"$SRC/.publish-forbidden"
+  guard
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"has an unknown section [strings report]"* ]] || return 1
+  [[ "$output" != *"needle 'backend#'"* ]] || return 1
 }
 
 @test "a missing forbidden list is could-not-tell for both scans that read it" {
@@ -402,24 +519,48 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
 }
 
 # ── the repo's OWN lists, fed inputs written here ─────────────────────────────
+# The inputs below are written independently of .publish-forbidden; the summary
+# line's needle counts are asserted so a needle added to either tier without a
+# planted input here reddens this file.
 
-@test "the committed .publish-forbidden refuses an internal reference planted in a fixture" {
+@test "the committed .publish-forbidden refuses every refuse-tier needle by name and spares the support mailbox" {
   cp "$REPO/.publish-forbidden" "$SRC/.publish-forbidden"
-  plant README.md 'rationale in backend#1'
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }    # README's support@tracebloc.io is allowed
+  [[ "$output" == *"[forbidden-strings] clean (3 refuse + 10 report needle(s), 1 allow token(s)"* ]] || { echo "$output"; return 1; }
+  rm -rf "$OUT"
+  plant README.md 'ask someone@tracebloc.io'
+  plant client/Chart.yaml '# role arn:aws:iam::000000000000:role/x'
+  plant scripts/lib/common.sh 'IMG=000000000000.dkr.ecr.eu-central-1.amazonaws.com/x'
   guard
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }
-  [[ "$output" == *"needle 'backend#' found in 1 staged line(s):"*"tree/README.md:2"* ]] || return 1
+  local needle
+  for needle in '[A-Za-z0-9._%+-]+@tracebloc\.io' 'arn:aws:' '[0-9]{12}\.dkr\.ecr\.'; do
+    [[ "$output" == *"REFUSED — [strings-refuse] needle '$needle' found in 1 staged line(s):"* ]] || { echo "missing: $needle"; echo "$output"; return 1; }
+  done
+  [[ "$output" == *"[forbidden-strings] 3 refuse-tier hit(s), 0 report-tier hit(s) counted"* ]] || return 1
 }
 
-@test "the committed .publish-forbidden refuses a non-production hostname and spares the support mailbox" {
+@test "the committed .publish-forbidden counts every report-tier needle by name, and --strict refuses them" {
   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 scripts/lib/common.sh 'A=https://dev-api.tracebloc.io/ B=https://stg-api.tracebloc.io/'
+  plant scripts/lib/common.sh 'C=https://dev.tracebloc.io/ D=https://stg.tracebloc.io/'
   guard
-  [ "$status" -eq 0 ] || { echo "$output"; return 1; }    # README's support@tracebloc.io is allowed
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  local needle
+  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
+    [[ "$output" == *"[strings-report] needle '$needle' found in 1 staged line(s) — counted, not refused"* ]] || { echo "missing: $needle"; echo "$output"; return 1; }
+  done
+  [[ "$output" == *"[strings-report] 10 hit(s) in 2 file(s); most-hit files:"* ]] || return 1
+  [[ "$output" == *"[forbidden-strings] 0 refuse-tier hit(s), 10 report-tier hit(s) counted (3 refuse + 10 report needle(s)"* ]] || return 1
   rm -rf "$OUT"
-  plant scripts/lib/common.sh 'API=https://stg-api.tracebloc.io/'
-  guard
-  [ "$status" -eq 1 ] || return 1
-  [[ "$output" == *"needle 'stg-api\.tracebloc\.io' found in 1 staged line(s):"*"tree/scripts/lib/common.sh:2"* ]] || return 1
+  guard --strict
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"REFUSED — [strings-report (strict)] needle 'backend#' found in 1 staged line(s):"*"tree/README.md:2"* ]] || return 1
+  [[ "$output" == *"REFUSED — [strings-report (strict)] needle 'stg\.tracebloc\.io' found in 1 staged line(s):"*"tree/scripts/lib/common.sh:3"* ]] || return 1
 }
 
 @test "the committed .publish-forbidden refuses each forbidden path class by name" {
@@ -443,13 +584,14 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
 
 @test "the committed .publish-include stages the deliverable of the real repo and nothing forbidden" {
   run bash "$GUARD" --source "$REPO" --out "$OUT"
-  # Not asserted clean: the strings scan has a known backlog in the chart and
-  # installer comments (internal tracker references) that is tracked separately;
-  # what this test pins is that the allowlist and path rules hold on the real
-  # tree and that the guard could evaluate it.
-  [ "$status" -le 1 ] || { echo "$output"; return 1; }
-  [[ "$output" != *"COULD NOT TELL"* ]] || { echo "$output"; return 1; }
+  # Asserted CLEAN: the refuse tier must hold on the real deliverable, and the
+  # report tier (the known backlog of internal references in the chart and the
+  # installers) is counted, not refused, until --strict is the policy. A
+  # refuse-tier needle landing in a deliverable file reddens this test — which
+  # is the point.
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
   [[ "$output" == *"[forbidden-paths] clean"* ]] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] 0 refuse-tier hit(s), "* || "$output" == *"[forbidden-strings] clean ("* ]] || { echo "$output"; return 1; }
   local f
   for f in client/Chart.yaml client/values.yaml ingestor/Chart.yaml scripts/install.sh scripts/install.ps1 scripts/install-k8s.sh scripts/lib/common.sh scripts/manifest.sha256 README.md LICENSE docs/INSTALL.md; do
     [ -f "$OUT/tree/$f" ] || { echo "not staged: $f"; return 1; }

From 03ec0ab4d45b7734e900081b0c3e6bae8defb65e Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 16:51:55 +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 and gh-pages pushes are 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.

scripts/tests/mirror-publish-workflow.bats 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, every 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. publish-mirror.bats 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.

Co-Authored-By: Claude Fable 5.1 
---
 .github/workflows/mirror-publish.yaml      | 150 ++++++--
 scripts/publish-mirror.sh                  |  38 +-
 scripts/tests/mirror-publish-workflow.bats | 402 +++++++++++++++++++++
 scripts/tests/publish-mirror.bats          |  47 +++
 4 files changed, 607 insertions(+), 30 deletions(-)
 create mode 100644 scripts/tests/mirror-publish-workflow.bats

diff --git a/.github/workflows/mirror-publish.yaml b/.github/workflows/mirror-publish.yaml
index e542c4ea..71475615 100644
--- a/.github/workflows/mirror-publish.yaml
+++ b/.github/workflows/mirror-publish.yaml
@@ -17,7 +17,8 @@
 #                      gh-pages index — a mirror cut then would copy a release
 #                      with half its assets. The completed chart workflow is the
 #                      moment every asset exists. head_branch of a release-
-#                      triggered run is the tag (measured on v1.9.109 / -rc.1).
+#                      triggered run is the tag (measured on v1.9.109 / -rc.1),
+#                      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,26 @@
 #   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 and
+#   the chart index are NOT pushed: they are what a customer installs from, and
+#   they keep the last stable release, exactly as the chart workflow keeps
+#   prereleases out of the Helm index. 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 +123,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. This is also the repository the gh-pages fetch reads.
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
       - name: Resolve what to publish
         id: plan
         env:
@@ -122,43 +151,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 and chart index are not pushed — they keep 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 +244,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 +275,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"
@@ -261,14 +340,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'
@@ -299,7 +378,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 }}
@@ -313,12 +394,12 @@ 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: Push the chart index to the mirror's gh-pages
-        if: steps.plan.outputs.dry_run != 'true'
+        if: steps.plan.outputs.dry_run != 'true' && steps.plan.outputs.publish_tree == 'true'
         env:
           MIRROR_TOKEN: ${{ steps.token.outputs.token }}
           REPO: ${{ steps.target.outputs.repo }}
@@ -327,13 +408,34 @@ jobs:
           set -euo pipefail
           bash scripts/publish-mirror.sh tree --stage "$RUNNER_TEMP/stage-pages/tree" --repo "$REPO" --branch gh-pages --message "Chart index at $TAG"
 
+      - 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' and gh-pages left untouched; release will be pinned to $SHA"
+
       - name: Create the release on the mirror
         if: steps.plan.outputs.dry_run != 'true'
         env:
           GH_TOKEN: ${{ steps.token.outputs.token }}
           REPO: ${{ steps.target.outputs.repo }}
           TAG: ${{ steps.plan.outputs.tag }}
-          SHA: ${{ steps.push.outputs.sha }}
+          SHA: ${{ steps.push.outputs.sha || steps.keep.outputs.sha }}
           PRERELEASE: ${{ steps.plan.outputs.prerelease }}
         run: |
           set -euo pipefail
diff --git a/scripts/publish-mirror.sh b/scripts/publish-mirror.sh
index 18f5e21e..c15d6be9 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.bats b/scripts/tests/mirror-publish-workflow.bats
new file mode 100644
index 00000000..8cbbcbe6
--- /dev/null
+++ b/scripts/tests/mirror-publish-workflow.bats
@@ -0,0 +1,402 @@
+#!/usr/bin/env bats
+# mirror-publish-workflow.bats — the decisions .github/workflows/mirror-publish.yaml
+# 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 (workspace rule 9). The gh shim
+# answers `release view` and `api` from env; the tag fetch runs against a real
+# bare repository over file://.
+#
+# WHAT IS 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 (Bugbot: "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
+#     (CodeQL: "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 `$(...)` (Bugbot:
+#     "captured output hides publish refusals")
+#
+# FAILS CLOSED: an unreadable workflow, a missing step id, or PyYAML absent is a
+# named refusal, never "nothing to check". The shape check is one function run
+# over the real workflow AND over mutated copies, so a mutation that reddens
+# here reddens the check that gates the tree.
+
+WF=""
+REPO_ROOT=""
+SHIM=""
+WORK=""
+
+setup() {
+  REPO_ROOT="$(cd "${BATS_TEST_DIRNAME}/../.." && pwd)"
+  WF="$REPO_ROOT/.github/workflows/mirror-publish.yaml"
+  python3 -c 'import yaml' 2>/dev/null || { echo "[ERROR] PyYAML required (pip install pyyaml)"; return 1; }
+  SHIM="$BATS_TEST_TMPDIR/shim"
+  WORK="$BATS_TEST_TMPDIR/work"
+  mkdir -p "$SHIM" "$WORK" "$BATS_TEST_TMPDIR/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 test 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="$BATS_TEST_TMPDIR/gh.log"
+  : >"$GH_LOG"
+  export GITHUB_OUTPUT="$BATS_TEST_TMPDIR/github-output"
+  : >"$GITHUB_OUTPUT"
+  export RUNNER_TEMP="$BATS_TEST_TMPDIR/runner-temp"
+  export GITHUB_REPOSITORY="example/source"
+  export GITHUB_WORKSPACE="$REPO_ROOT"
+  # The plan step's job-level env, every field set (the body runs under set -u).
+  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=""
+}
+
+SHA_A=1111111111111111111111111111111111111111
+SHA_B=2222222222222222222222222222222222222222
+
+# 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.
+run_step() {
+  local body="$BATS_TEST_TMPDIR/step-$1.sh"
+  step_run "$WF" "$1" >"$body" || { cat "$body"; return 1; }
+  local dir="${2:-$WORK}"
+  run env PATH="$SHIM:$PATH" bash -c "cd '$dir' && bash '$body'"
+}
+
+out() { grep -E "^$1=" "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2-; }
+
+release_json() { #  
+  printf '{"tagName":"%s","isDraft":false,"isPrerelease":%s}' "$1" "$2"
+}
+
+# ── plan ──────────────────────────────────────────────────────────────────────
+
+@test "plan: a stable release from workflow_run publishes tree and release, pinned to head_sha" {
+  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON
+  GH_RELEASE_JSON="$(release_json v1.2.3 false)"
+  run_step plan
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(out tag)" = "v1.2.3" ] || return 1
+  [ "$(out dry_run)" = "false" ] || return 1
+  [ "$(out prerelease)" = "false" ] || return 1
+  [ "$(out publish_tree)" = "true" ] || return 1
+  [ "$(out expect_sha)" = "$SHA_A" ] || return 1
+  grep -q '^release view v1.2.3 --repo example/source --json tagName,isDraft,isPrerelease$' "$GH_LOG" || return 1
+  [[ "$output" != *"::notice::"*"prerelease"* ]] || return 1
+}
+
+@test "plan: a prerelease mirrors only its release — publish_tree=false, and the log says why" {
+  export RUN_HEAD_BRANCH=v1.2.3-rc.1 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON
+  GH_RELEASE_JSON="$(release_json v1.2.3-rc.1 true)"
+  run_step plan
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(out prerelease)" = "true" ] || return 1
+  [ "$(out publish_tree)" = "false" ] || return 1
+  [ "$(out expect_sha)" = "$SHA_A" ] || return 1
+  [[ "$output" == *"::notice::'v1.2.3-rc.1' is a prerelease: only its GitHub release is mirrored (marked prerelease)."*"default branch and chart index are not pushed"* ]] || { echo "$output"; return 1; }
+}
+
+@test "plan: a workflow_run whose head is a branch, not a tag, is refused before anything is read" {
+  export RUN_HEAD_BRANCH=develop RUN_HEAD_SHA="$SHA_A"
+  run_step plan
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::'develop' is not a release tag"* ]] || return 1
+  [ ! -s "$GITHUB_OUTPUT" ] || return 1
+  [ ! -s "$GH_LOG" ] || return 1
+}
+
+@test "plan: a release whose tag_name is not the run's tag is refused" {
+  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON
+  GH_RELEASE_JSON="$(release_json v9.9.9 false)"
+  run_step plan
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::release 'v1.2.3' reports tag_name 'v9.9.9' — the tag and the release disagree, refusing."* ]] || return 1
+  [ ! -s "$GITHUB_OUTPUT" ] || return 1
+}
+
+@test "plan: a workflow_run without a full head_sha to pin the tag to is refused" {
+  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA=abc123 GH_RELEASE_JSON
+  GH_RELEASE_JSON="$(release_json v1.2.3 false)"
+  run_step plan
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::cannot determine the commit release 'v1.2.3' was cut from (got 'abc123')"* ]] || return 1
+  [ ! -s "$GITHUB_OUTPUT" ] || return 1
+}
+
+@test "plan: a dispatch takes the expected commit from the API and is a dry run unless told 'false'" {
+  export EVENT_NAME=workflow_dispatch INPUT_TAG=v1.2.3 INPUT_DRY_RUN=true GH_RELEASE_JSON GH_API_SHA="$SHA_B"
+  GH_RELEASE_JSON="$(release_json v1.2.3 false)"
+  run_step plan
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(out dry_run)" = "true" ] || return 1
+  [ "$(out expect_sha)" = "$SHA_B" ] || return 1
+  grep -q '^api repos/example/source/commits/v1.2.3 --jq .sha$' "$GH_LOG" || return 1
+  # The API not answering is "cannot tell", never an unpinned fetch.
+  : >"$GITHUB_OUTPUT"
+  GH_API_RC=1 run_step plan
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::cannot determine the commit release 'v1.2.3' was cut from (got '')"* ]] || return 1
+}
+
+# ── 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
+  local seed="$BATS_TEST_TMPDIR/seed" bare="$BATS_TEST_TMPDIR/origin.git"
+  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
+  rm -rf "$WORK"
+  git clone -q "file://$bare" "$WORK" 2>/dev/null
+  git -C "$WORK" rev-parse HEAD
+}
+
+@test "src: the tag is fetched into a detached worktree outside the checkout, only at the expected commit" {
+  local sha
+  sha="$(make_origin)"
+  export TAG=v1.2.3 EXPECT_SHA="$sha"
+  run_step src
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(out dir)" = "$RUNNER_TEMP/release-src" ] || return 1
+  [ "$(git -C "$RUNNER_TEMP/release-src" rev-parse HEAD)" = "$sha" ] || return 1
+  [ -f "$RUNNER_TEMP/release-src/README.md" ] || return 1
+  [[ "$output" == *"release source: v1.2.3 at $sha (data only)"* ]] || return 1
+}
+
+@test "src: a tag that does not resolve to the expected commit is refused and nothing is checked out" {
+  make_origin >/dev/null
+  export TAG=v1.2.3 EXPECT_SHA="$SHA_B"
+  run_step src
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::tag 'v1.2.3' resolves to "*" but the release was cut at $SHA_B — the tag has moved or the run is not this release's; refusing."* ]] || return 1
+  [ ! -e "$RUNNER_TEMP/release-src" ] || return 1
+  [ ! -s "$GITHUB_OUTPUT" ] || return 1
+}
+
+@test "src: a tag origin does not have is refused" {
+  make_origin >/dev/null
+  export TAG=v9.9.9 EXPECT_SHA="$SHA_A"
+  run_step src
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::could not fetch tag 'v9.9.9' from origin"* ]] || return 1
+  [ ! -e "$RUNNER_TEMP/release-src" ] || return 1
+}
+
+# ── target / keep: refusals annotate, results go to GITHUB_OUTPUT ─────────────
+
+@test "target: an unset MIRROR_REPO is refused with the ::error:: line IN THE STEP LOG, nothing captured" {
+  export VAR_MIRROR="" INPUT_MIRROR=""
+  run_step target "$REPO_ROOT"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::publish-mirror: REFUSED — no mirror repository is configured (MIRROR_REPO is unset)"* ]] || { echo "$output"; return 1; }
+  [ ! -s "$GITHUB_OUTPUT" ] || return 1
+}
+
+@test "target: a configured mirror lands in GITHUB_OUTPUT as repo= and name=" {
+  export VAR_MIRROR=source-public INPUT_MIRROR=""
+  run_step target "$REPO_ROOT"
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(out repo)" = "example/source-public" ] || return 1
+  [ "$(out name)" = "source-public" ] || return 1
+}
+
+@test "keep: a prerelease is pinned to the mirror's default-branch head; an empty mirror is refused" {
+  export REPO=example/source-public BRANCH=main TAG=v1.2.3-rc.1 GH_API_SHA="$SHA_B"
+  run_step keep
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(out sha)" = "$SHA_B" ] || return 1
+  grep -q '^api repos/example/source-public/commits/main --jq .sha$' "$GH_LOG" || return 1
+  [[ "$output" == *"prerelease v1.2.3-rc.1: default branch 'main' and gh-pages left untouched"* ]] || return 1
+  : >"$GITHUB_OUTPUT"
+  GH_API_RC=1 run_step keep
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::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"* ]] || return 1
+  [ ! -s "$GITHUB_OUTPUT" ] || return 1
+}
+
+# ── 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 steps invoke the
+# publisher), never from a list of step names held here.
+shape() {
+  run python3 - "$1" <<'PY'
+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
+}
+
+# mutate  — write a mutated copy of the real
+# workflow and print its path. The mutation is applied to the PARSED document,
+# and asserted to have changed it, so an inert edit cannot pass as coverage.
+mutate() {
+  local out="$BATS_TEST_TMPDIR/mutated-$BATS_TEST_NUMBER.yaml"
+  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
+}
+
+@test "shape: the real workflow — no checkout ref, tree pushes gated, release ungated, nothing captured, one pinned tag fetch" {
+  shape "$WF"
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"OK: 1 checkout step(s), none with a ref"* ]] || { echo "$output"; return 1; }
+  [[ "$output" == *"OK: 2 tree push step(s), each gated on publish_tree"* ]] || { echo "$output"; return 1; }
+  [[ "$output" == *"OK: the release step is not gated on publish_tree"* ]] || return 1
+  [[ "$output" == *"OK: no step captures the publisher's output"* ]] || return 1
+  [[ "$output" == *"OK: the one tag fetch compares against the expected commit"* ]] || return 1
+}
+
+@test "shape mutation: a checkout that takes a ref reddens" {
+  local m
+  m="$(mutate "[s for s in steps if str(s.get('uses','')).startswith('actions/checkout')][0]['with'] = {'ref': '\${{ steps.plan.outputs.tag }}'}")" || return 1
+  shape "$m"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"FAIL: checkout step "*"takes a ref"* ]] || { echo "$output"; return 1; }
+}
+
+@test "shape mutation: a tree push without the publish_tree gate reddens" {
+  local m
+  m="$(mutate "s = [s for s in steps if s.get('id') == 'push'][0]; s['if'] = \"steps.plan.outputs.dry_run != 'true'\"")" || return 1
+  shape "$m"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"FAIL: step "*"pushes a tree without"* ]] || { echo "$output"; return 1; }
+}
+
+@test "shape mutation: capturing the publisher through \$(...) reddens" {
+  local m
+  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'")" || return 1
+  shape "$m"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"FAIL: step "*"captures publish-mirror.sh through"* ]] || { echo "$output"; return 1; }
+}
+
+@test "shape mutation: a tag fetch that skips the EXPECT_SHA comparison reddens" {
+  local m
+  m="$(mutate "s = [s for s in steps if s.get('id') == 'src'][0]; s['run'] = s['run'].replace('EXPECT_SHA', 'IGNORED')")" || return 1
+  shape "$m"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"FAIL: the tag fetch step does not compare against EXPECT_SHA"* ]] || { echo "$output"; return 1; }
+}
diff --git a/scripts/tests/publish-mirror.bats b/scripts/tests/publish-mirror.bats
index 0bdec71e..36ce620b 100644
--- a/scripts/tests/publish-mirror.bats
+++ b/scripts/tests/publish-mirror.bats
@@ -81,6 +81,29 @@ mirror_files() { git -C "$BARE" ls-tree -r --name-only "$1" | sort | paste -sd'
   [[ "$output" == *"COULD NOT TELL — target: --source-repo is required"* ]] || return 1
 }
 
+# --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).
+
+@test "target: --output writes repo= and name= for the workflow; stdout still names the mirror" {
+  local out="$BATS_TEST_TMPDIR/out"
+  pub target --mirror client-public --source-repo tracebloc/client --output "$out"
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$output" = "tracebloc/client-public" ] || return 1
+  [ "$(cat "$out")" = $'repo=tracebloc/client-public\nname=client-public' ] || { cat "$out"; return 1; }
+}
+
+@test "target: a refusal puts the ::error:: line on stdout and writes nothing to --output" {
+  local out="$BATS_TEST_TMPDIR/out"
+  pub target --mirror '' --source-repo tracebloc/client --output "$out"
+  [ "$status" -eq 1 ] || return 1
+  [[ "$output" == "::error::publish-mirror: REFUSED — no mirror repository is configured"* ]] || { echo "$output"; return 1; }
+  [ ! -e "$out" ] || return 1
+  pub target --mirror client --source-repo tracebloc/client --output "$out"
+  [ "$status" -eq 1 ] || return 1
+  [ ! -e "$out" ] || return 1
+}
+
 # ── tree ──────────────────────────────────────────────────────────────────────
 
 @test "tree: the first publish starts the branch and the mirror holds exactly the stage" {
@@ -143,6 +166,30 @@ mirror_files() { git -C "$BARE" ls-tree -r --name-only "$1" | sort | paste -sd'
   [[ "$output" == *"contains a .git entry"* ]] || return 1
 }
 
+@test "tree: --output writes result= and sha= (pushed, then unchanged); a refusal writes nothing and annotates stdout" {
+  local out="$BATS_TEST_TMPDIR/out"
+  tree --output "$out"
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(sed -n 1p "$out")" = "result=pushed" ] || { cat "$out"; return 1; }
+  [ "$(sed -n 2p "$out")" = "sha=$(git -C "$BARE" rev-parse main)" ] || { cat "$out"; return 1; }
+  rm "$out"
+  tree --output "$out"
+  [ "$status" -eq 0 ] || return 1
+  [ "$(sed -n 1p "$out")" = "result=unchanged" ] || { cat "$out"; return 1; }
+  [ "$(sed -n 2p "$out")" = "sha=$(git -C "$BARE" rev-parse main)" ] || return 1
+  rm "$out"
+  pub tree --stage "$STAGE" --repo tracebloc/mirror --branch main --message m --remote "file://$BATS_TEST_TMPDIR/no-such.git" --output "$out"
+  [ "$status" -eq 2 ] || return 1
+  [[ "$output" == "::error::publish-mirror: COULD NOT TELL — tree: the mirror remote did not answer"* ]] || { echo "$output"; return 1; }
+  [ ! -e "$out" ] || return 1
+}
+
+@test "tree: an unwritable --output is could-not-tell — a result the caller never receives is not a publish" {
+  tree --output "$BATS_TEST_TMPDIR/no-such-dir/out"
+  [ "$status" -eq 2 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"COULD NOT TELL — could not write results to"* ]] || return 1
+}
+
 @test "tree: the script never forces a push" {
   run grep -nE -- '--force|\+refs/|-f[[:space:]]' "$PUB"
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }

From a5f172bbb7fa27c88237d037dcbac2b993759a4f Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 16:58:28 +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 test'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. 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.bats | 9 +++++++--
 1 file changed, 7 insertions(+), 2 deletions(-)

diff --git a/scripts/tests/mirror-publish-workflow.bats b/scripts/tests/mirror-publish-workflow.bats
index 8cbbcbe6..072cfb56 100644
--- a/scripts/tests/mirror-publish-workflow.bats
+++ b/scripts/tests/mirror-publish-workflow.bats
@@ -184,9 +184,14 @@ release_json() { #  
 
 # ── 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
+make_origin() { # a bare origin with one commit tagged v1.2.3 (annotated); WORK becomes its clone; prints the commit
   local seed="$BATS_TEST_TMPDIR/seed" bare="$BATS_TEST_TMPDIR/origin.git"
   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
@@ -195,7 +200,7 @@ make_origin() { # a bare origin with one commit tagged v1.2.3 (annotated); WORK
   git -C "$seed" push -q "file://$bare" HEAD:refs/heads/main refs/tags/v1.2.3
   rm -rf "$WORK"
   git clone -q "file://$bare" "$WORK" 2>/dev/null
-  git -C "$WORK" rev-parse HEAD
+  git -C "$seed" rev-parse --verify HEAD
 }
 
 @test "src: the tag is fetched into a detached worktree outside the checkout, only at the expected commit" {

From 7b64b2cff4642140cd7e9166fad99acdbba4c1ad Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 17:38:28 +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
cli 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.bats: 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.bats | 24 +++++++++++--
 2 files changed, 62 insertions(+), 20 deletions(-)

diff --git a/scripts/publish-guard.sh b/scripts/publish-guard.sh
index ff7349e7..ef1aae27 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.bats b/scripts/tests/publish-guard.bats
index b4d59cf2..dbb95033 100644
--- a/scripts/tests/publish-guard.bats
+++ b/scripts/tests/publish-guard.bats
@@ -251,13 +251,33 @@ staged() { ( cd "$OUT/tree" && find . -type f | sed 's|^\./||' | sort ); }
   [[ "$output" == *"[strings-refuse] needle '[A-Za-z0-9._%+-]+@tracebloc\.io' found in 1 staged line(s):"*"tree/README.md:2"* ]] || return 1
 }
 
-@test "mutation: a private needle supplied with --extra-forbidden joins the refuse tier" {
+@test "mutation: an [allow] token is stripped as a whole word only — a mailbox that merely ends in it is refused" {
+  # 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.
+  plant README.md 'escalate to devsupport@tracebloc.io'
+  guard
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[strings-refuse] needle '[A-Za-z0-9._%+-]+@tracebloc\.io' found in 1 staged line(s):"*"tree/README.md:2"* ]] || return 1
+}
+
+@test "an [allow] token matches case-insensitively, like the scan, and a sentence-ending dot is still a boundary" {
+  plant README.md 'Questions? Write to Support@Tracebloc.io.'
+  guard
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"[forbidden-strings] clean ("* ]] || return 1
+}
+
+@test "mutation: a private needle supplied with --extra-forbidden joins the refuse tier, named by number only" {
   printf '# private list\nplanted-tenant\n' >"$BATS_TEST_TMPDIR/tenants.txt"
   plant client/templates/deploy.yaml '# for Planted-Tenant only'
   guard --extra-forbidden "$BATS_TEST_TMPDIR/tenants.txt"
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }
-  [[ "$output" == *"[strings-refuse] needle 'planted-tenant' found in 1 staged line(s):"*"tree/client/templates/deploy.yaml:2"* ]] || return 1
+  [[ "$output" == *"[strings-refuse] private needle #1 found in 1 staged line(s):"*"tree/client/templates/deploy.yaml:2"* ]] || return 1
   [[ "$output" == *"[forbidden-strings] 1 refuse-tier hit(s), 0 report-tier hit(s) counted (3 refuse + 3 report needle(s)"* ]] || return 1
+  # 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.
+  ! grep -qi 'planted-tenant' <<<"$output" || { echo "$output"; return 1; }
+  ! grep -qi 'planted-tenant' "$OUT/publish-guard-report.txt" || return 1
 }
 
 @test "an empty --extra-forbidden list is could-not-tell: the private needles were not supplied" {

From 1fb3ea9030bd2a5cb1275a00051917df2594541c Mon Sep 17 00:00:00 2001
From: Lukas Wuttke 
Date: Thu, 10 Sep 2026 17:49:11 +0200
Subject: [PATCH 6/7] ci(mirror): only the newest stable release replaces the
 mirror's default branch
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

A re-run of the chart workflow for an earlier tag, or a dispatch naming one,
used to push that tag's tree to the mirror's default branch and chart index,
rolling the public README, installer and charts back — and, when that release
was already mirrored, stopping there with the rollback in place.

The plan step now asks GitHub for the source repository's releases/latest and
sets publish_tree=false for any stable tag that is not it; the release is
still mirrored, pinned to the mirror's current default-branch head. An
unreadable releases/latest refuses the run rather than guessing.

Tests (mirror-publish-workflow.bats, executing the real step bodies): the
newest stable release publishes the tree and asks releases/latest of the
source repo; an older stable tag sets publish_tree=false with a notice and is
not marked prerelease; an unreadable releases/latest is refused; a mutation
that removes the comparison lets the older tag publish the tree (so the test
is live). The shape check now derives the step that decides publish_tree and
refuses one that never reads releases/latest, mutation-proved.

Co-Authored-By: Claude Fable 5.1 
---
 .github/workflows/mirror-publish.yaml      | 44 ++++++++---
 scripts/tests/mirror-publish-workflow.bats | 89 +++++++++++++++++++---
 2 files changed, 113 insertions(+), 20 deletions(-)

diff --git a/.github/workflows/mirror-publish.yaml b/.github/workflows/mirror-publish.yaml
index 71475615..aed665fc 100644
--- a/.github/workflows/mirror-publish.yaml
+++ b/.github/workflows/mirror-publish.yaml
@@ -57,6 +57,16 @@
 #   prereleases out of the Helm index. A prerelease therefore cannot be the
 #   first thing published to an empty mirror — the run refuses and says so.
 #
+# Older stable releases
+#   The default branch and the chart index are replaced only by the NEWEST
+#   stable release of this repository (what GitHub reports as releases/latest).
+#   A re-run of the chart workflow for an earlier tag, or a dispatch naming one,
+#   mirrors that release only — otherwise a rebuild of v1.8.0 after v1.9.0 had
+#   shipped would roll the public README, installer and charts back to v1.8.0
+#   and, if v1.8.0 was already mirrored, stop there with the rollback in place.
+#   When releases/latest cannot be read the run refuses: "cannot tell" does not
+#   replace what customers install from.
+#
 # 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
@@ -189,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 and chart index are not pushed — they keep the last stable release."
+            else
+              # Only the NEWEST stable release replaces the mirror's default
+              # branch and chart index. A re-run for an older tag (or a dispatch
+              # naming one) would otherwise roll the public tree back to it.
+              # releases/latest is GitHub's own answer, not a sort done here.
+              LATEST="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" || LATEST=""
+              if [ -z "$LATEST" ]; then
+                echo "::error::cannot read the newest stable release of $GITHUB_REPOSITORY (releases/latest) — refusing: without it this run cannot tell whether '$TAG' may replace the mirror's default branch."
+                exit 1
+              fi
+              if [ "$LATEST" != "$TAG" ]; then
+                PUBLISH_TREE=false
+                echo "::notice::'$TAG' is not the newest stable release ($LATEST is): only its GitHub release is mirrored. The mirror's default branch and chart index are not pushed — they keep the newest stable release."
+              fi
             fi
           else
             if [ "$DRY_RUN" != "true" ]; then
@@ -378,8 +402,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: neither a prerelease nor an older
+        # stable tag 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:
@@ -408,11 +433,12 @@ jobs:
           set -euo pipefail
           bash scripts/publish-mirror.sh tree --stage "$RUNNER_TEMP/stage-pages/tree" --repo "$REPO" --branch gh-pages --message "Chart index at $TAG"
 
-      - 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: Release only — 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:
+        # neither can 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:
@@ -423,11 +449,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 its release to — the first publish to an empty mirror must be the newest stable release."
             exit 1
           fi
           echo "sha=$SHA" >>"$GITHUB_OUTPUT"
-          echo "prerelease $TAG: default branch '$BRANCH' and gh-pages left untouched; release will be pinned to $SHA"
+          echo "release-only $TAG: default branch '$BRANCH' and gh-pages 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.bats b/scripts/tests/mirror-publish-workflow.bats
index 072cfb56..d5e92b6a 100644
--- a/scripts/tests/mirror-publish-workflow.bats
+++ b/scripts/tests/mirror-publish-workflow.bats
@@ -15,6 +15,10 @@
 #   * 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 (Bugbot: "prerelease overwrites public default branch")
+#   * a stable release that is NOT the newest one (releases/latest of the source
+#     repo) also sets publish_tree=false, and an unreadable releases/latest is
+#     refused — a rebuild or dispatch of an older tag must not roll the mirror's
+#     default branch back (Bugbot: "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 expects
@@ -41,8 +45,9 @@ setup() {
   WORK="$BATS_TEST_TMPDIR/work"
   mkdir -p "$SHIM" "$WORK" "$BATS_TEST_TMPDIR/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 test 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 test can assert WHICH question the step asked.
   cat >"$SHIM/gh" <<'EOF'
 #!/usr/bin/env bash
 printf '%s\n' "$*" >>"${GH_LOG:?}"
@@ -50,6 +55,9 @@ 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 "*"/releases/latest")
+    [ "${GH_LATEST_RC:-0}" -eq 0 ] || { echo "HTTP 404: Not Found" >&2; exit "$GH_LATEST_RC"; }
+    printf '%s\n' "${GH_LATEST_TAG:?}" ;;
   "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:?}" ;;
@@ -99,10 +107,12 @@ PY
 }
 
 # run_step  [cwd] — execute the step body as Actions would: its own
-# bash, the exported env, the gh shim first on PATH.
+# bash, the exported env, the gh shim first on PATH. RUN_WF names the workflow
+# to read the body from (default: the real one; a mutation test points it at a
+# mutated copy so the same step body runs with one decision removed).
 run_step() {
   local body="$BATS_TEST_TMPDIR/step-$1.sh"
-  step_run "$WF" "$1" >"$body" || { cat "$body"; return 1; }
+  step_run "${RUN_WF:-$WF}" "$1" >"$body" || { cat "$body"; return 1; }
   local dir="${2:-$WORK}"
   run env PATH="$SHIM:$PATH" bash -c "cd '$dir' && bash '$body'"
 }
@@ -115,8 +125,8 @@ release_json() { #  
 
 # ── plan ──────────────────────────────────────────────────────────────────────
 
-@test "plan: a stable release from workflow_run publishes tree and release, pinned to head_sha" {
-  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON
+@test "plan: the newest stable release from workflow_run publishes tree and release, pinned to head_sha" {
+  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON GH_LATEST_TAG=v1.2.3
   GH_RELEASE_JSON="$(release_json v1.2.3 false)"
   run_step plan
   [ "$status" -eq 0 ] || { echo "$output"; return 1; }
@@ -126,7 +136,9 @@ release_json() { #  
   [ "$(out publish_tree)" = "true" ] || return 1
   [ "$(out expect_sha)" = "$SHA_A" ] || return 1
   grep -q '^release view v1.2.3 --repo example/source --json tagName,isDraft,isPrerelease$' "$GH_LOG" || return 1
-  [[ "$output" != *"::notice::"*"prerelease"* ]] || return 1
+  # The newest-stable answer is GitHub's, asked of the SOURCE repository.
+  grep -q '^api repos/example/source/releases/latest --jq .tag_name$' "$GH_LOG" || { cat "$GH_LOG"; return 1; }
+  [[ "$output" != *"::notice::"* ]] || return 1
 }
 
 @test "plan: a prerelease mirrors only its release — publish_tree=false, and the log says why" {
@@ -138,6 +150,41 @@ release_json() { #  
   [ "$(out publish_tree)" = "false" ] || return 1
   [ "$(out expect_sha)" = "$SHA_A" ] || return 1
   [[ "$output" == *"::notice::'v1.2.3-rc.1' is a prerelease: only its GitHub release is mirrored (marked prerelease)."*"default branch and chart index are not pushed"* ]] || { echo "$output"; return 1; }
+  # A prerelease is never the newest stable release; the question is not asked.
+  ! grep -q 'releases/latest' "$GH_LOG" || return 1
+}
+
+@test "plan: a stable release that is not the newest one mirrors only its release — publish_tree=false, not marked prerelease" {
+  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON GH_LATEST_TAG=v1.3.0
+  GH_RELEASE_JSON="$(release_json v1.2.3 false)"
+  run_step plan
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  [ "$(out tag)" = "v1.2.3" ] || return 1
+  [ "$(out prerelease)" = "false" ] || return 1
+  [ "$(out publish_tree)" = "false" ] || return 1
+  [ "$(out expect_sha)" = "$SHA_A" ] || return 1
+  [[ "$output" == *"::notice::'v1.2.3' is not the newest stable release (v1.3.0 is): only its GitHub release is mirrored."*"they keep the newest stable release"* ]] || { echo "$output"; return 1; }
+}
+
+@test "plan: an unreadable releases/latest is refused — 'cannot tell' does not replace the default branch" {
+  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON GH_LATEST_TAG="" GH_LATEST_RC=1
+  GH_RELEASE_JSON="$(release_json v1.2.3 false)"
+  run_step plan
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::cannot read the newest stable release of example/source (releases/latest) — refusing"* ]] || { echo "$output"; return 1; }
+  [ ! -s "$GITHUB_OUTPUT" ] || return 1
+}
+
+@test "plan mutation: with the newest-stable comparison removed, an older tag publishes the tree — the test above catches it" {
+  local m
+  m="$(mutate "s = [s for s in steps if s.get('id') == 'plan'][0]; s['run'] = s['run'].replace('if [ \"\$LATEST\" != \"\$TAG\" ]; then', 'if false; then')")" || return 1
+  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON GH_LATEST_TAG=v1.3.0
+  GH_RELEASE_JSON="$(release_json v1.2.3 false)"
+  RUN_WF="$m" run_step plan
+  [ "$status" -eq 0 ] || { echo "$output"; return 1; }
+  # The mutated body lets the older tag through: this is the outcome the real
+  # test refuses, so the assertion there is live, not vacuous.
+  [ "$(out publish_tree)" = "true" ] || { echo "$output"; return 1; }
 }
 
 @test "plan: a workflow_run whose head is a branch, not a tag, is refused before anything is read" {
@@ -168,11 +215,12 @@ release_json() { #  
 }
 
 @test "plan: a dispatch takes the expected commit from the API and is a dry run unless told 'false'" {
-  export EVENT_NAME=workflow_dispatch INPUT_TAG=v1.2.3 INPUT_DRY_RUN=true GH_RELEASE_JSON GH_API_SHA="$SHA_B"
+  export EVENT_NAME=workflow_dispatch INPUT_TAG=v1.2.3 INPUT_DRY_RUN=true GH_RELEASE_JSON GH_API_SHA="$SHA_B" GH_LATEST_TAG=v1.2.3
   GH_RELEASE_JSON="$(release_json v1.2.3 false)"
   run_step plan
   [ "$status" -eq 0 ] || { echo "$output"; return 1; }
   [ "$(out dry_run)" = "true" ] || return 1
+  [ "$(out publish_tree)" = "true" ] || return 1
   [ "$(out expect_sha)" = "$SHA_B" ] || return 1
   grep -q '^api repos/example/source/commits/v1.2.3 --jq .sha$' "$GH_LOG" || return 1
   # The API not answering is "cannot tell", never an unpinned fetch.
@@ -252,17 +300,17 @@ make_origin() { # a bare origin with one commit tagged v1.2.3 (annotated); WORK
   [ "$(out name)" = "source-public" ] || return 1
 }
 
-@test "keep: a prerelease is pinned to the mirror's default-branch head; an empty mirror is refused" {
+@test "keep: a release-only publish is pinned to the mirror's default-branch head; an empty mirror is refused" {
   export REPO=example/source-public BRANCH=main TAG=v1.2.3-rc.1 GH_API_SHA="$SHA_B"
   run_step keep
   [ "$status" -eq 0 ] || { echo "$output"; return 1; }
   [ "$(out sha)" = "$SHA_B" ] || return 1
   grep -q '^api repos/example/source-public/commits/main --jq .sha$' "$GH_LOG" || return 1
-  [[ "$output" == *"prerelease v1.2.3-rc.1: default branch 'main' and gh-pages left untouched"* ]] || return 1
+  [[ "$output" == *"release-only v1.2.3-rc.1: default branch 'main' and gh-pages left untouched"* ]] || { echo "$output"; return 1; }
   : >"$GITHUB_OUTPUT"
   GH_API_RC=1 run_step keep
   [ "$status" -eq 1 ] || { echo "$output"; return 1; }
-  [[ "$output" == *"::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"* ]] || return 1
+  [[ "$output" == *"::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 its release to — the first publish to an empty mirror must be the newest stable release."* ]] || { echo "$output"; return 1; }
   [ ! -s "$GITHUB_OUTPUT" ] || return 1
 }
 
@@ -336,6 +384,16 @@ if len(fetches) != 1:
 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")
+
+# The step that DECIDES publish_tree (writes it to GITHUB_OUTPUT) must ask
+# GitHub which release is the newest stable one; a decision that never asks
+# would let a rebuild of an older tag replace the mirror's default branch.
+deciders = [s for s in steps if re.search(r"publish_tree=", str(s.get("run", "")))]
+if len(deciders) != 1:
+    fail("expected exactly one step writing publish_tree=, found %d" % len(deciders))
+if "releases/latest" not in str(deciders[0].get("run", "")):
+    fail("step %r decides publish_tree without reading releases/latest — an older stable tag would replace the mirror's default branch" % deciders[0].get("name"))
+print("OK: the publish_tree decision reads releases/latest")
 PY
 }
 
@@ -372,6 +430,15 @@ PY
   [[ "$output" == *"OK: the release step is not gated on publish_tree"* ]] || return 1
   [[ "$output" == *"OK: no step captures the publisher's output"* ]] || return 1
   [[ "$output" == *"OK: the one tag fetch compares against the expected commit"* ]] || return 1
+  [[ "$output" == *"OK: the publish_tree decision reads releases/latest"* ]] || { echo "$output"; return 1; }
+}
+
+@test "shape mutation: a publish_tree decision that never reads releases/latest reddens" {
+  local m
+  m="$(mutate "s = [s for s in steps if s.get('id') == 'plan'][0]; s['run'] = s['run'].replace('releases/latest', 'releases/tags/latest')")" || return 1
+  shape "$m"
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"FAIL: step "*"decides publish_tree without reading releases/latest"* ]] || { echo "$output"; return 1; }
 }
 
 @test "shape mutation: a checkout that takes a ref reddens" {

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

Two review findings on the workflow body.

isPrerelease was compared only against an exact `true`, so a release object
missing the field (or carrying anything else) read as stable and left the
default-branch and gh-pages pushes armed. The plan step now accepts exactly
`true` or `false` and refuses everything else.

Actions runs every `run:` body with -e, which `set -uo pipefail` does not turn
off, so a refusing guard exited the step at the tee pipeline before the step
summary was written or `exit "$rc"` ran. Both guard steps now catch the guard's
status with `|| rc=$?`, write the summary, and exit with that status. The steps
gained ids (guard-tree, guard-pages) so their bodies are testable.

Tests (mirror-publish-workflow.bats): step bodies now run under `bash -e`, as
in CI; a release JSON without isPrerelease is refused; the guard-tree body with
a planted refusing guard writes the summary and exits 1; the mutation that
drops `|| rc=$?` reproduces the finding (exit 1, empty summary).

Co-Authored-By: Claude Fable 5.1 
---
 .github/workflows/mirror-publish.yaml      | 19 ++++++--
 scripts/tests/mirror-publish-workflow.bats | 57 +++++++++++++++++++++-
 2 files changed, 71 insertions(+), 5 deletions(-)

diff --git a/.github/workflows/mirror-publish.yaml b/.github/workflows/mirror-publish.yaml
index aed665fc..4b593419 100644
--- a/.github/workflows/mirror-publish.yaml
+++ b/.github/workflows/mirror-publish.yaml
@@ -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
@@ -331,6 +341,7 @@ jobs:
         # lists. A missing gh-pages is not "nothing to do": the chart workflow
         # just pushed to it, so its absence means this run is looking at the
         # wrong repository.
+        id: guard-pages
         env:
           STRICT: ${{ steps.plan.outputs.strict }}
         run: |
@@ -340,8 +351,8 @@ jobs:
           args=(--source "$RUNNER_TEMP/pages-src" --include .publish-include-pages --forbidden .publish-forbidden
                 --extra-forbidden "$RUNNER_TEMP/tenants.txt" --out "$RUNNER_TEMP/stage-pages")
           [ "$STRICT" != "true" ] || args+=(--strict)
-          bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-pages.log"
-          rc=${PIPESTATUS[0]}
+          rc=0
+          bash scripts/publish-guard.sh "${args[@]}" | tee "$RUNNER_TEMP/guard-pages.log" || rc=$?
           {
             echo "## Mirror publish — chart index (gh-pages)"
             echo
diff --git a/scripts/tests/mirror-publish-workflow.bats b/scripts/tests/mirror-publish-workflow.bats
index d5e92b6a..12d506ac 100644
--- a/scripts/tests/mirror-publish-workflow.bats
+++ b/scripts/tests/mirror-publish-workflow.bats
@@ -26,6 +26,12 @@
 #   * a refusal from publish-mirror.sh is a `::error::` line in the step's
 #     stdout, so no step captures the publisher through `$(...)` (Bugbot:
 #     "captured output hides publish refusals")
+#   * isPrerelease must be an explicit boolean: a missing or malformed value is
+#     refused, never read as "stable" (Bugbot: "prerelease tree-push guard
+#     fails open")
+#   * a guard refusal still lands in the step summary and the step exits with
+#     the guard's status — under the `-e` Actions runs every body with (Bugbot:
+#     "guard refusal skips step summary")
 #
 # FAILS CLOSED: an unreadable workflow, a missing step id, or PyYAML absent is a
 # named refusal, never "nothing to check". The shape check is one function run
@@ -114,7 +120,10 @@ run_step() {
   local body="$BATS_TEST_TMPDIR/step-$1.sh"
   step_run "${RUN_WF:-$WF}" "$1" >"$body" || { cat "$body"; return 1; }
   local dir="${2:-$WORK}"
-  run env PATH="$SHIM:$PATH" bash -c "cd '$dir' && bash '$body'"
+  # `bash -e`: what Actions runs a `run:` body with. A body that relies on
+  # surviving a failing command (the guard steps' tee pipeline) is tested under
+  # the same errexit it gets in CI, or the test proves nothing about the step.
+  run env PATH="$SHIM:$PATH" bash -c "cd '$dir' && bash -e '$body'"
 }
 
 out() { grep -E "^$1=" "$GITHUB_OUTPUT" | tail -1 | cut -d= -f2-; }
@@ -154,6 +163,15 @@ release_json() { #  
   ! grep -q 'releases/latest' "$GH_LOG" || return 1
 }
 
+@test "plan: a release whose isPrerelease is not a boolean is refused — the tree push is armed only by an explicit false" {
+  export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON GH_LATEST_TAG=v1.2.3
+  GH_RELEASE_JSON='{"tagName":"v1.2.3","isDraft":false}'
+  run_step plan
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"::error::release 'v1.2.3' reports isPrerelease 'null' — not a boolean, refusing"* ]] || { echo "$output"; return 1; }
+  [ ! -s "$GITHUB_OUTPUT" ] || return 1
+}
+
 @test "plan: a stable release that is not the newest one mirrors only its release — publish_tree=false, not marked prerelease" {
   export RUN_HEAD_BRANCH=v1.2.3 RUN_HEAD_SHA="$SHA_A" GH_RELEASE_JSON GH_LATEST_TAG=v1.3.0
   GH_RELEASE_JSON="$(release_json v1.2.3 false)"
@@ -314,6 +332,43 @@ make_origin() { # a bare origin with one commit tagged v1.2.3 (annotated); WORK
   [ ! -s "$GITHUB_OUTPUT" ] || return 1
 }
 
+# ── guard steps: 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.
+fake_guard() {
+  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
+}
+
+@test "guard-tree: a guard refusal is written to the step summary and the step exits with the guard's status" {
+  fake_guard "$WORK"
+  export GITHUB_STEP_SUMMARY="$BATS_TEST_TMPDIR/summary.md" TAG="" STRICT="" SRC_DIR=""
+  : >"$GITHUB_STEP_SUMMARY"
+  run_step guard-tree
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  [[ "$output" == *"REFUSED — planted refusal"* ]] || { echo "$output"; return 1; }
+  grep -q '^## Mirror publish — tree$' "$GITHUB_STEP_SUMMARY" || { cat "$GITHUB_STEP_SUMMARY"; return 1; }
+  grep -q 'REFUSED — planted refusal' "$GITHUB_STEP_SUMMARY" || { cat "$GITHUB_STEP_SUMMARY"; return 1; }
+}
+
+@test "guard-tree mutation: without catching the guard's status, errexit skips the summary — the test above catches it" {
+  local m
+  m="$(mutate "s = [s for s in steps if s.get('id') == 'guard-tree'][0]; s['run'] = s['run'].replace(' || rc=\$?', '')")" || return 1
+  fake_guard "$WORK"
+  export GITHUB_STEP_SUMMARY="$BATS_TEST_TMPDIR/summary.md" TAG="" STRICT="" SRC_DIR=""
+  : >"$GITHUB_STEP_SUMMARY"
+  RUN_WF="$m" run_step guard-tree
+  [ "$status" -eq 1 ] || { echo "$output"; return 1; }
+  # The refusal happened, but the summary is empty: the finding, reproduced.
+  [ ! -s "$GITHUB_STEP_SUMMARY" ] || { cat "$GITHUB_STEP_SUMMARY"; return 1; }
+}
+
 # ── shape: derived from the workflow, one implementation for real and mutated ──
 
 # shape  — OK lines / one FAIL line. Every rule is derived from