diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml index eb709bd9..e221ba84 100644 --- a/.github/workflows/build-image.yml +++ b/.github/workflows/build-image.yml @@ -10,6 +10,9 @@ on: # still skipped. paths: - "Dockerfile" + - "ROLLBACK_UNSAFE_BEFORE" + - "scripts/check-rollback-boundary.sh" + - "scripts/test-check-rollback-boundary.sh" - "go.mod" - "go.sum" - "cmd/**" @@ -77,6 +80,20 @@ jobs: dockerfile: tests/sdk-monitor/Dockerfile steps: - uses: actions/checkout@v7 + 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 @@ -85,6 +102,21 @@ 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 + 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) + 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 +149,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/.github/workflows/test.yml b/.github/workflows/test.yml index ed03b1ee..bc0aa91e 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 dab6928a..cf1d9856 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -286,6 +286,15 @@ 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 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 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/ROLLBACK_UNSAFE_BEFORE b/ROLLBACK_UNSAFE_BEFORE new file mode 100644 index 00000000..621e94f0 --- /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 9f29f92e..d1c00d70 100755 --- a/scripts/check-repository-text-integrity.sh +++ b/scripts/check-repository-text-integrity.sh @@ -21,6 +21,13 @@ node -e ' node scripts/sync-agent-docs.mjs --check node scripts/check-sdk-example-contracts.mjs +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 + 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 \ diff --git a/scripts/check-rollback-boundary.sh b/scripts/check-rollback-boundary.sh new file mode 100755 index 00000000..16c9cc6e --- /dev/null +++ b/scripts/check-rollback-boundary.sh @@ -0,0 +1,72 @@ +#!/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. 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 HEAD history chronologically, including every commit in a PR. +base_ref=${ROLLBACK_BASE_REF:-${1:-}} +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 + if [ -z "$historical_boundary" ]; then + [ "$value" = none ] || historical_boundary="$value" + elif [ "$value" != "$historical_boundary" ]; then + echo "ROLLBACK_UNSAFE_BEFORE changed historically after ${historical_boundary}: ${value} at ${commit}" >&2 + exit 1 + fi +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 + exit 1 +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 00000000..b1c3e85c --- /dev/null +++ b/scripts/test-check-rollback-boundary.sh @@ -0,0 +1,91 @@ +#!/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 + +# 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 + +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 + 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 + +# 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"