From a957947da5bb1be8dbffea39e0cc8d05f18897df Mon Sep 17 00:00:00 2001 From: Jace Date: Sat, 1 Aug 2026 17:50:02 -0700 Subject: [PATCH 1/5] feat(release): declare rollback compatibility boundary --- .github/workflows/build-image.yml | 21 ++++++++++++++++++++- CONTRIBUTING.md | 6 ++++++ ROLLBACK_UNSAFE_BEFORE | 1 + scripts/check-repository-text-integrity.sh | 11 +++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 ROLLBACK_UNSAFE_BEFORE diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index eb709bd9c..575f6263c 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -10,6 +10,7 @@ on: # still skipped. paths: - "Dockerfile" + - "ROLLBACK_UNSAFE_BEFORE" - "go.mod" - "go.sum" - "cmd/**" @@ -85,6 +86,22 @@ jobs: - uses: docker/setup-qemu-action@v4 - uses: docker/setup-buildx-action@v4 + # Hosted release automation refuses to infer rollback safety from a + # missing label. The tracked value is `none` while all migrations remain + # backward-compatible; once an irreversible migration ships, set its + # first affected release and carry that boundary forward cumulatively. + - name: Validate rollback compatibility boundary + id: rollback-boundary + shell: bash + run: | + set -euo pipefail + value=$(tr -d '[:space:]' < ROLLBACK_UNSAFE_BEFORE) + if ! [[ "$value" =~ ^(none|[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?)$ ]]; then + echo "ROLLBACK_UNSAFE_BEFORE must be 'none' or an exact semver" >&2 + exit 1 + fi + echo "value=$value" >> "$GITHUB_OUTPUT" + # Skip login on PR builds: we don't push from a PR, and PRs from # forks only get a read-only GITHUB_TOKEN anyway. - name: Log in to GHCR @@ -117,7 +134,9 @@ jobs: # push on real branch/tag/manual events. push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} + labels: | + ${{ steps.meta.outputs.labels }} + org.e2a.rollback-unsafe-before=${{ steps.rollback-boundary.outputs.value }} platforms: linux/amd64,linux/arm64 # Per-image cache scopes — without scoping, the two matrix # entries would clobber each other's cache and rebuild from diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dab6928ad..f78a4a908 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -286,6 +286,12 @@ pending migrations against a `schema_migrations` tracker table. renumber existing files. 4. **Forward-only** — there are no down migrations. If you need to undo something, write a new migration that does it. +5. **Declare binary rollback compatibility** — `ROLLBACK_UNSAFE_BEFORE` is + embedded as `org.e2a.rollback-unsafe-before` in release images. Leave it at + `none` only while the previous released binary can still run safely after + every embedded migration applies. If a migration crosses that boundary, + set the first affected release semver and carry it forward cumulatively; + never reset it to `none` in a later release. Add or update tests in any Go package that writes raw SQL against the touched table. Higher-level e2e coverage doesn't catch query drift diff --git a/ROLLBACK_UNSAFE_BEFORE b/ROLLBACK_UNSAFE_BEFORE new file mode 100644 index 000000000..621e94f0e --- /dev/null +++ b/ROLLBACK_UNSAFE_BEFORE @@ -0,0 +1 @@ +none diff --git a/scripts/check-repository-text-integrity.sh b/scripts/check-repository-text-integrity.sh index 9f29f92e6..f4a913f12 100755 --- a/scripts/check-repository-text-integrity.sh +++ b/scripts/check-repository-text-integrity.sh @@ -21,6 +21,17 @@ node -e ' node scripts/sync-agent-docs.mjs --check node scripts/check-sdk-example-contracts.mjs +rollback_boundary="$(tr -d '[:space:]' < ROLLBACK_UNSAFE_BEFORE)" +if ! [[ "$rollback_boundary" =~ ^(none|[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?)$ ]]; then + echo "ROLLBACK_UNSAFE_BEFORE must be 'none' or an exact semver" >&2 + exit 1 +fi +if ! grep -Fq 'org.e2a.rollback-unsafe-before=${{ steps.rollback-boundary.outputs.value }}' \ + .github/workflows/build-image.yml; then + echo "server image build must publish the rollback compatibility boundary" >&2 + exit 1 +fi + legacy_agent_calls="$(git grep -n -E 'webhooks\.(fetch_message|fetchMessage)|client\.messages\.reply|messages\.reply' -- \ examples/agent-framework-webhooks/python/agent_webhooks \ examples/agent-framework-webhooks/typescript/src \ From 3dd56d9ab7d82f00be10d6e60a868882c5b22ffb Mon Sep 17 00:00:00 2001 From: Jace Date: Sat, 1 Aug 2026 18:06:41 -0700 Subject: [PATCH 2/5] fix(release): enforce cumulative rollback boundary --- .github/workflows/build-image.yml | 12 ++++--- .github/workflows/test.yml | 5 +++ CONTRIBUTING.md | 9 ++--- scripts/check-repository-text-integrity.sh | 6 +--- scripts/check-rollback-boundary.sh | 25 ++++++++++++++ scripts/test-check-rollback-boundary.sh | 38 ++++++++++++++++++++++ 6 files changed, 81 insertions(+), 14 deletions(-) create mode 100755 scripts/check-rollback-boundary.sh create mode 100755 scripts/test-check-rollback-boundary.sh diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index 575f6263c..edecad5d3 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -11,6 +11,8 @@ on: paths: - "Dockerfile" - "ROLLBACK_UNSAFE_BEFORE" + - "scripts/check-rollback-boundary.sh" + - "scripts/test-check-rollback-boundary.sh" - "go.mod" - "go.sum" - "cmd/**" @@ -78,6 +80,8 @@ jobs: dockerfile: tests/sdk-monitor/Dockerfile steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 # QEMU registers binfmt_misc handlers so this amd64 runner can # cross-build arm64 layers under emulation. Without this step the @@ -93,13 +97,11 @@ jobs: - name: Validate rollback compatibility boundary id: rollback-boundary shell: bash + env: + ROLLBACK_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || '' }} run: | set -euo pipefail - value=$(tr -d '[:space:]' < ROLLBACK_UNSAFE_BEFORE) - if ! [[ "$value" =~ ^(none|[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?)$ ]]; then - echo "ROLLBACK_UNSAFE_BEFORE must be 'none' or an exact semver" >&2 - exit 1 - fi + value=$(scripts/check-rollback-boundary.sh) echo "value=$value" >> "$GITHUB_OUTPUT" # Skip login on PR builds: we don't push from a PR, and PRs from diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed03b1ee0..bc0aa91e6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -40,10 +40,15 @@ jobs: timeout-minutes: 5 steps: - uses: actions/checkout@v7 + with: + fetch-depth: 0 - uses: actions/setup-node@v7 with: node-version: "22" - run: scripts/check-repository-text-integrity.sh + env: + ROLLBACK_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || '' }} + - run: scripts/test-check-rollback-boundary.sh - run: node --test scripts/sync-agent-docs.test.mjs harness-tests: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f78a4a908..8aaa47696 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -288,10 +288,11 @@ pending migrations against a `schema_migrations` tracker table. undo something, write a new migration that does it. 5. **Declare binary rollback compatibility** — `ROLLBACK_UNSAFE_BEFORE` is embedded as `org.e2a.rollback-unsafe-before` in release images. Leave it at - `none` only while the previous released binary can still run safely after - every embedded migration applies. If a migration crosses that boundary, - set the first affected release semver and carry it forward cumulatively; - never reset it to `none` in a later release. + `none` only while every rollback-eligible retained production binary can + still run safely after every embedded migration applies. Hosted S1 enforces + that claim by booting those exact image digests against its migrated prod + clone. If a migration crosses that boundary, set the first affected release + semver and carry it forward unchanged; CI rejects any later reset or move. Add or update tests in any Go package that writes raw SQL against the touched table. Higher-level e2e coverage doesn't catch query drift diff --git a/scripts/check-repository-text-integrity.sh b/scripts/check-repository-text-integrity.sh index f4a913f12..d1c00d707 100755 --- a/scripts/check-repository-text-integrity.sh +++ b/scripts/check-repository-text-integrity.sh @@ -21,11 +21,7 @@ node -e ' node scripts/sync-agent-docs.mjs --check node scripts/check-sdk-example-contracts.mjs -rollback_boundary="$(tr -d '[:space:]' < ROLLBACK_UNSAFE_BEFORE)" -if ! [[ "$rollback_boundary" =~ ^(none|[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?)$ ]]; then - echo "ROLLBACK_UNSAFE_BEFORE must be 'none' or an exact semver" >&2 - exit 1 -fi +scripts/check-rollback-boundary.sh >/dev/null if ! grep -Fq 'org.e2a.rollback-unsafe-before=${{ steps.rollback-boundary.outputs.value }}' \ .github/workflows/build-image.yml; then echo "server image build must publish the rollback compatibility boundary" >&2 diff --git a/scripts/check-rollback-boundary.sh b/scripts/check-rollback-boundary.sh new file mode 100755 index 000000000..c0ccd4efa --- /dev/null +++ b/scripts/check-rollback-boundary.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +boundary="$(tr -d '[:space:]' < ROLLBACK_UNSAFE_BEFORE)" +if ! [[ "$boundary" =~ ^(none|[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?)$ ]]; then + echo "ROLLBACK_UNSAFE_BEFORE must be 'none' or an exact semver" >&2 + exit 1 +fi + +# Once a release crosses a migration boundary, every descendant must carry the +# same first-unsafe release. A PR/push cannot reset or move that cumulative +# boundary. The caller supplies the immutable base SHA used by CI. +base_ref=${ROLLBACK_BASE_REF:-${1:-}} +if [ -n "$base_ref" ] && git cat-file -e "${base_ref}:ROLLBACK_UNSAFE_BEFORE" 2>/dev/null; then + previous="$(git show "${base_ref}:ROLLBACK_UNSAFE_BEFORE" | tr -d '[:space:]')" + if [ "$previous" != none ] && [ "$boundary" != "$previous" ]; then + echo "ROLLBACK_UNSAFE_BEFORE is cumulative: ${previous} cannot change to ${boundary}" >&2 + exit 1 + fi +fi + +printf '%s\n' "$boundary" diff --git a/scripts/test-check-rollback-boundary.sh b/scripts/test-check-rollback-boundary.sh new file mode 100755 index 000000000..d69fb9768 --- /dev/null +++ b/scripts/test-check-rollback-boundary.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +source_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +tmp=$(mktemp -d "${TMPDIR:-/tmp}/rollback-boundary-test.XXXXXX") +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/scripts" +cp "$source_root/scripts/check-rollback-boundary.sh" "$tmp/scripts/" +chmod +x "$tmp/scripts/check-rollback-boundary.sh" + +git -C "$tmp" init -q +git -C "$tmp" config user.name Test +git -C "$tmp" config user.email test@example.com +printf '%s\n' none > "$tmp/ROLLBACK_UNSAFE_BEFORE" +git -C "$tmp" add ROLLBACK_UNSAFE_BEFORE +git -C "$tmp" commit -qm safe +safe_sha=$(git -C "$tmp" rev-parse HEAD) + +printf '%s\n' 2.0.0 > "$tmp/ROLLBACK_UNSAFE_BEFORE" +ROLLBACK_BASE_REF="$safe_sha" "$tmp/scripts/check-rollback-boundary.sh" >/dev/null +git -C "$tmp" commit -qam boundary +boundary_sha=$(git -C "$tmp" rev-parse HEAD) + +printf '%s\n' none > "$tmp/ROLLBACK_UNSAFE_BEFORE" +if ROLLBACK_BASE_REF="$boundary_sha" "$tmp/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then + echo "resetting a cumulative rollback boundary unexpectedly passed" >&2 + exit 1 +fi + +printf '%s\n' 2.1.0 > "$tmp/ROLLBACK_UNSAFE_BEFORE" +if ROLLBACK_BASE_REF="$boundary_sha" "$tmp/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then + echo "moving a cumulative rollback boundary unexpectedly passed" >&2 + exit 1 +fi + +printf '%s\n' 2.0.0 > "$tmp/ROLLBACK_UNSAFE_BEFORE" +ROLLBACK_BASE_REF="$boundary_sha" "$tmp/scripts/check-rollback-boundary.sh" >/dev/null +echo "rollback boundary checks: PASS" From e535383094e9ec18ba511082769782e3c822416c Mon Sep 17 00:00:00 2001 From: Jace Date: Sat, 1 Aug 2026 18:39:57 -0700 Subject: [PATCH 3/5] fix(release): close rollback boundary publish gaps --- .github/workflows/build-image.yml | 13 ++++++ CONTRIBUTING.md | 4 +- scripts/check-rollback-boundary.sh | 60 ++++++++++++++++++++++--- scripts/test-check-rollback-boundary.sh | 15 +++++++ 4 files changed, 85 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index edecad5d3..e221ba841 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -83,6 +83,18 @@ jobs: with: fetch-depth: 0 + # Tags and workflow_dispatch can otherwise publish from an arbitrary + # branch. Release images must come from a commit already contained in + # main so the cumulative rollback-boundary history cannot be forked. + - name: Require publish source on main + if: github.event_name != 'pull_request' + shell: bash + run: | + set -euo pipefail + git fetch --force origin refs/heads/main:refs/remotes/origin/main + git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/main \ + || { echo "::error::Image publishing is allowed only for commits contained in main"; exit 1; } + # QEMU registers binfmt_misc handlers so this amd64 runner can # cross-build arm64 layers under emulation. Without this step the # arm64 build fails with "exec format error" — buildx alone does @@ -99,6 +111,7 @@ jobs: shell: bash env: ROLLBACK_BASE_REF: ${{ github.event.pull_request.base.sha || github.event.before || '' }} + REQUIRE_ROLLBACK_BASE: true run: | set -euo pipefail value=$(scripts/check-rollback-boundary.sh) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8aaa47696..cf1d98563 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -292,7 +292,9 @@ pending migrations against a `schema_migrations` tracker table. still run safely after every embedded migration applies. Hosted S1 enforces that claim by booting those exact image digests against its migrated prod clone. If a migration crosses that boundary, set the first affected release - semver and carry it forward unchanged; CI rejects any later reset or move. + semver and carry it forward unchanged; CI scans the reachable file history + and rejects any later reset or move. Tag and manual image publishing are + also restricted to commits already contained in `main`. Add or update tests in any Go package that writes raw SQL against the touched table. Higher-level e2e coverage doesn't catch query drift diff --git a/scripts/check-rollback-boundary.sh b/scripts/check-rollback-boundary.sh index c0ccd4efa..46564bce0 100755 --- a/scripts/check-rollback-boundary.sh +++ b/scripts/check-rollback-boundary.sh @@ -11,15 +11,63 @@ if ! [[ "$boundary" =~ ^(none|[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?)$ ]]; then fi # Once a release crosses a migration boundary, every descendant must carry the -# same first-unsafe release. A PR/push cannot reset or move that cumulative -# boundary. The caller supplies the immutable base SHA used by CI. +# same first-unsafe release. Comparing only one caller-supplied snapshot is not +# enough: a tag/manual publish could omit that ref after an earlier commit had +# already reset the file. Resolve a trusted ancestor, then scan the file's full +# reachable history for the first cumulative non-none value. base_ref=${ROLLBACK_BASE_REF:-${1:-}} -if [ -n "$base_ref" ] && git cat-file -e "${base_ref}:ROLLBACK_UNSAFE_BEFORE" 2>/dev/null; then - previous="$(git show "${base_ref}:ROLLBACK_UNSAFE_BEFORE" | tr -d '[:space:]')" - if [ "$previous" != none ] && [ "$boundary" != "$previous" ]; then - echo "ROLLBACK_UNSAFE_BEFORE is cumulative: ${previous} cannot change to ${boundary}" >&2 +require_base=${REQUIRE_ROLLBACK_BASE:-false} + +case "$require_base" in + true|false) ;; + *) echo "REQUIRE_ROLLBACK_BASE must be true or false" >&2; exit 1 ;; +esac + +if [ -z "$base_ref" ] || [[ "$base_ref" =~ ^0+$ ]]; then + base_ref="" + if git rev-parse --verify HEAD^ >/dev/null 2>&1; then + base_ref=HEAD^ + fi +fi + +if [ -z "$base_ref" ]; then + if [ "$require_base" = true ]; then + echo "a resolvable rollback-boundary base commit is required for publishing" >&2 + exit 1 + fi + printf '%s\n' "$boundary" + exit 0 +fi + +if ! git cat-file -e "${base_ref}^{commit}" 2>/dev/null; then + echo "rollback-boundary base is not a commit: ${base_ref}" >&2 + exit 1 +fi +if ! git merge-base --is-ancestor "$base_ref" HEAD; then + echo "rollback-boundary base ${base_ref} is not an ancestor of HEAD" >&2 + exit 1 +fi + +historical_boundary="" +while IFS= read -r commit; do + value="$(git show "${commit}:ROLLBACK_UNSAFE_BEFORE" 2>/dev/null | tr -d '[:space:]' || true)" + [ -n "$value" ] || continue + if ! [[ "$value" =~ ^(none|[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9.]+)?)$ ]]; then + echo "historical ROLLBACK_UNSAFE_BEFORE at ${commit} is invalid: ${value}" >&2 exit 1 fi + [ "$value" != none ] || continue + if [ -z "$historical_boundary" ]; then + historical_boundary="$value" + elif [ "$value" != "$historical_boundary" ]; then + echo "ROLLBACK_UNSAFE_BEFORE changed historically: ${historical_boundary} vs ${value}" >&2 + exit 1 + fi +done < <(git log --format=%H "$base_ref" -- ROLLBACK_UNSAFE_BEFORE) + +if [ -n "$historical_boundary" ] && [ "$boundary" != "$historical_boundary" ]; then + echo "ROLLBACK_UNSAFE_BEFORE is cumulative: ${historical_boundary} cannot change to ${boundary}" >&2 + exit 1 fi printf '%s\n' "$boundary" diff --git a/scripts/test-check-rollback-boundary.sh b/scripts/test-check-rollback-boundary.sh index d69fb9768..ffe910f26 100755 --- a/scripts/test-check-rollback-boundary.sh +++ b/scripts/test-check-rollback-boundary.sh @@ -27,6 +27,21 @@ if ROLLBACK_BASE_REF="$boundary_sha" "$tmp/scripts/check-rollback-boundary.sh" > exit 1 fi +# Publishing entry points such as tag push and workflow_dispatch may not have a +# useful event.before. Commit the reset and prove the checker derives HEAD^ and +# still finds the earlier cumulative value in history. +git -C "$tmp" commit -qam reset +if REQUIRE_ROLLBACK_BASE=true "$tmp/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then + echo "history-aware reset with an omitted base unexpectedly passed" >&2 + exit 1 +fi + +if ROLLBACK_BASE_REF=not-a-commit REQUIRE_ROLLBACK_BASE=true \ + "$tmp/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then + echo "an unresolvable required base unexpectedly passed" >&2 + exit 1 +fi + printf '%s\n' 2.1.0 > "$tmp/ROLLBACK_UNSAFE_BEFORE" if ROLLBACK_BASE_REF="$boundary_sha" "$tmp/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then echo "moving a cumulative rollback boundary unexpectedly passed" >&2 From 5dbd2ea1a5b7d020e3573a92f32d0fdb27603db4 Mon Sep 17 00:00:00 2001 From: Jace Date: Sat, 1 Aug 2026 18:46:01 -0700 Subject: [PATCH 4/5] fix(release): scan complete rollback history --- scripts/check-rollback-boundary.sh | 9 ++++----- scripts/test-check-rollback-boundary.sh | 11 +++++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/check-rollback-boundary.sh b/scripts/check-rollback-boundary.sh index 46564bce0..a738cf8ee 100755 --- a/scripts/check-rollback-boundary.sh +++ b/scripts/check-rollback-boundary.sh @@ -14,7 +14,7 @@ fi # same first-unsafe release. Comparing only one caller-supplied snapshot is not # enough: a tag/manual publish could omit that ref after an earlier commit had # already reset the file. Resolve a trusted ancestor, then scan the file's full -# reachable history for the first cumulative non-none value. +# reachable HEAD history chronologically, including every commit in a PR. base_ref=${ROLLBACK_BASE_REF:-${1:-}} require_base=${REQUIRE_ROLLBACK_BASE:-false} @@ -56,14 +56,13 @@ while IFS= read -r commit; do echo "historical ROLLBACK_UNSAFE_BEFORE at ${commit} is invalid: ${value}" >&2 exit 1 fi - [ "$value" != none ] || continue if [ -z "$historical_boundary" ]; then - historical_boundary="$value" + [ "$value" = none ] || historical_boundary="$value" elif [ "$value" != "$historical_boundary" ]; then - echo "ROLLBACK_UNSAFE_BEFORE changed historically: ${historical_boundary} vs ${value}" >&2 + echo "ROLLBACK_UNSAFE_BEFORE changed historically after ${historical_boundary}: ${value} at ${commit}" >&2 exit 1 fi -done < <(git log --format=%H "$base_ref" -- ROLLBACK_UNSAFE_BEFORE) +done < <(git log --reverse --format=%H HEAD -- ROLLBACK_UNSAFE_BEFORE) if [ -n "$historical_boundary" ] && [ "$boundary" != "$historical_boundary" ]; then echo "ROLLBACK_UNSAFE_BEFORE is cumulative: ${historical_boundary} cannot change to ${boundary}" >&2 diff --git a/scripts/test-check-rollback-boundary.sh b/scripts/test-check-rollback-boundary.sh index ffe910f26..63bc1907a 100755 --- a/scripts/test-check-rollback-boundary.sh +++ b/scripts/test-check-rollback-boundary.sh @@ -36,12 +36,23 @@ if REQUIRE_ROLLBACK_BASE=true "$tmp/scripts/check-rollback-boundary.sh" >/dev/nu exit 1 fi +printf '%s\n' 2.0.0 > "$tmp/ROLLBACK_UNSAFE_BEFORE" +if REQUIRE_ROLLBACK_BASE=true "$tmp/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then + echo "a transient committed reset hidden by a later restore unexpectedly passed" >&2 + exit 1 +fi + if ROLLBACK_BASE_REF=not-a-commit REQUIRE_ROLLBACK_BASE=true \ "$tmp/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then echo "an unresolvable required base unexpectedly passed" >&2 exit 1 fi +# Return to the clean boundary commit. The committed reset remains proven above +# but must not contaminate the unchanged-boundary success case below. +git -C "$tmp" restore ROLLBACK_UNSAFE_BEFORE +git -C "$tmp" checkout -q "$boundary_sha" + printf '%s\n' 2.1.0 > "$tmp/ROLLBACK_UNSAFE_BEFORE" if ROLLBACK_BASE_REF="$boundary_sha" "$tmp/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then echo "moving a cumulative rollback boundary unexpectedly passed" >&2 From 05ddf7c28f96c521f1acf6683636794524529624 Mon Sep 17 00:00:00 2001 From: Jace Date: Sat, 1 Aug 2026 18:50:37 -0700 Subject: [PATCH 5/5] fix(release): inspect merged rollback history --- scripts/check-rollback-boundary.sh | 2 +- scripts/test-check-rollback-boundary.sh | 27 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/scripts/check-rollback-boundary.sh b/scripts/check-rollback-boundary.sh index a738cf8ee..16c9cc6ef 100755 --- a/scripts/check-rollback-boundary.sh +++ b/scripts/check-rollback-boundary.sh @@ -62,7 +62,7 @@ while IFS= read -r commit; do echo "ROLLBACK_UNSAFE_BEFORE changed historically after ${historical_boundary}: ${value} at ${commit}" >&2 exit 1 fi -done < <(git log --reverse --format=%H HEAD -- ROLLBACK_UNSAFE_BEFORE) +done < <(git log --full-history --topo-order --reverse --format=%H HEAD -- ROLLBACK_UNSAFE_BEFORE) if [ -n "$historical_boundary" ] && [ "$boundary" != "$historical_boundary" ]; then echo "ROLLBACK_UNSAFE_BEFORE is cumulative: ${historical_boundary} cannot change to ${boundary}" >&2 diff --git a/scripts/test-check-rollback-boundary.sh b/scripts/test-check-rollback-boundary.sh index 63bc1907a..b1c3e85cd 100755 --- a/scripts/test-check-rollback-boundary.sh +++ b/scripts/test-check-rollback-boundary.sh @@ -61,4 +61,31 @@ fi printf '%s\n' 2.0.0 > "$tmp/ROLLBACK_UNSAFE_BEFORE" ROLLBACK_BASE_REF="$boundary_sha" "$tmp/scripts/check-rollback-boundary.sh" >/dev/null + +# Default path-history simplification can hide a boundary commit on a merged +# side branch when the merge keeps main's `none`. Full history must still see +# that reachable boundary and reject publishing the merge result as safe. +merge_repo="$tmp/merge-history" +mkdir -p "$merge_repo/scripts" +cp "$source_root/scripts/check-rollback-boundary.sh" "$merge_repo/scripts/" +chmod +x "$merge_repo/scripts/check-rollback-boundary.sh" +git -C "$merge_repo" init -q -b main +git -C "$merge_repo" config user.name Test +git -C "$merge_repo" config user.email test@example.com +printf '%s\n' none > "$merge_repo/ROLLBACK_UNSAFE_BEFORE" +git -C "$merge_repo" add ROLLBACK_UNSAFE_BEFORE +git -C "$merge_repo" commit -qm safe-root +git -C "$merge_repo" checkout -qb boundary-side +printf '%s\n' 2.0.0 > "$merge_repo/ROLLBACK_UNSAFE_BEFORE" +git -C "$merge_repo" commit -qam side-boundary +git -C "$merge_repo" checkout -q main +printf '%s\n' unrelated > "$merge_repo/README.md" +git -C "$merge_repo" add README.md +git -C "$merge_repo" commit -qm main-work +git -C "$merge_repo" merge -q --no-ff -s ours boundary-side -m merged-side-history +if REQUIRE_ROLLBACK_BASE=true "$merge_repo/scripts/check-rollback-boundary.sh" >/dev/null 2>&1; then + echo "a rollback boundary hidden on a merged side branch unexpectedly passed" >&2 + exit 1 +fi + echo "rollback boundary checks: PASS"