diff --git a/.fernignore b/.fernignore index b7708faa..c22dfbdd 100644 --- a/.fernignore +++ b/.fernignore @@ -9,3 +9,5 @@ changelog.md .fern/replay.lock .fern/replay.yml .gitattributes + +.github/scripts/verify-release-commit.sh diff --git a/.github/scripts/validate-release.test.cjs b/.github/scripts/validate-release.test.cjs index 9bf90306..7a704728 100644 --- a/.github/scripts/validate-release.test.cjs +++ b/.github/scripts/validate-release.test.cjs @@ -69,3 +69,83 @@ test("package job validates the release before installing or building", () => { assert.ok(validation < packageJob.indexOf("run: pnpm build")); assert.ok(validation < packageJob.indexOf("npm pack")); }); + +function gitFixture(run) { + const cwd = mkdtempSync(join(tmpdir(), "sdk-release-history-")); + const env = { + ...process.env, + GIT_AUTHOR_NAME: "Release test", GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Release test", GIT_COMMITTER_EMAIL: "test@example.com", + }; + const git = (...args) => { + const result = spawnSync("git", args, { cwd, env, encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); + }; + try { + git("init", "--initial-branch=main"); + git("commit", "--allow-empty", "-m", "Approved release"); + const approved = git("rev-parse", "HEAD"); + git("update-ref", "refs/remotes/origin/main", approved); + Object.assign(env, { GITHUB_REF: "refs/tags/v3.0.0", GITHUB_REF_NAME: "v3.0.0", GITHUB_SHA: approved }); + const guard = () => spawnSync("bash", [resolve(__dirname, "verify-release-commit.sh")], { cwd, env, encoding: "utf8" }); + run({ git, guard, env, approved }); + } finally { + rmSync(cwd, { recursive: true, force: true }); + } +} + +test("allows a release commit on main, including an older approved commit", () => { + gitFixture(({ git, guard, approved }) => { + assert.equal(guard().status, 0); + git("commit", "--allow-empty", "-m", "Newer approved commit"); + git("update-ref", "refs/remotes/origin/main", git("rev-parse", "HEAD")); + git("checkout", "--detach", approved); + assert.equal(guard().status, 0); + }); +}); + +test("resolves annotated tag objects to their approved commit", () => { + gitFixture(({ git, guard, env }) => { + git("tag", "-a", "v3.0.0", "-m", "Release"); + env.GITHUB_SHA = git("rev-parse", "v3.0.0"); + assert.equal(guard().status, 0); + }); +}); + +test("rejects an unmerged release commit", () => { + gitFixture(({ git, guard, env }) => { + git("checkout", "-b", "unapproved"); + git("commit", "--allow-empty", "-m", "Unreviewed change"); + env.GITHUB_SHA = git("rev-parse", "HEAD"); + const result = guard(); + assert.equal(result.status, 1); + assert.match(result.stdout, /must already be merged/); + }); +}); + +test("rejects a checkout that differs from the release event", () => { + gitFixture(({ git, guard }) => { + git("commit", "--allow-empty", "-m", "Different checkout"); + const result = guard(); + assert.equal(result.status, 1); + assert.match(result.stdout, /does not match/); + }); +}); + +test("fails closed when main or the event commit is missing", () => { + gitFixture(({ git, guard, env }) => { + git("update-ref", "-d", "refs/remotes/origin/main"); + assert.notEqual(guard().status, 0); + delete env.GITHUB_SHA; + assert.equal(guard().status, 1); + }); +}); + +test("rejects branch refs masquerading as release tags", () => { + gitFixture(({ guard, env }) => { + env.GITHUB_REF = "refs/heads/refs/tags/v3.0.0"; + env.GITHUB_REF_NAME = "refs/tags/v3.0.0"; + assert.equal(guard().status, 1); + }); +}); diff --git a/.github/scripts/verify-release-commit.sh b/.github/scripts/verify-release-commit.sh new file mode 100644 index 00000000..e5e88e1a --- /dev/null +++ b/.github/scripts/verify-release-commit.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Fetch origin/main immediately before calling this script. Never release a +# commit that has only appeared on an unreviewed branch or tag. +if [[ -z "${GITHUB_REF_NAME:-}" || "${GITHUB_REF:-}" != "refs/tags/${GITHUB_REF_NAME}" || -z "${GITHUB_SHA:-}" ]]; then + echo "::error::Release requires a tag push and an event commit." + exit 1 +fi + +release_commit=$(git rev-parse --verify "${GITHUB_SHA}^{commit}") +checkout_commit=$(git rev-parse --verify 'HEAD^{commit}') +if [[ "$checkout_commit" != "$release_commit" ]]; then + echo "::error::Checkout does not match the release event commit." + exit 1 +fi + +if ! git merge-base --is-ancestor "$release_commit" refs/remotes/origin/main; then + echo "::error::Release commit must already be merged into protected main." + exit 1 +fi + +echo "Release commit $release_commit is on protected main." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14f60fc8..098cfa1c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,7 @@ jobs: uses: actions/checkout@v6 with: persist-credentials: false + fetch-depth: 0 - name: Set up node uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 @@ -72,6 +73,11 @@ jobs: node-version: "24" package-manager-cache: false + - name: Verify release commit is on main + run: | + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + bash .github/scripts/verify-release-commit.sh + - name: Validate release tag run: node .github/scripts/validate-release.cjs