Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion .github/workflows/build-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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/**"
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions ROLLBACK_UNSAFE_BEFORE
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
none
7 changes: 7 additions & 0 deletions scripts/check-repository-text-integrity.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
72 changes: 72 additions & 0 deletions scripts/check-rollback-boundary.sh
Original file line number Diff line number Diff line change
@@ -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"
91 changes: 91 additions & 0 deletions scripts/test-check-rollback-boundary.sh
Original file line number Diff line number Diff line change
@@ -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"