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