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