diff --git a/.github/workflows/release-latest-tag.yaml b/.github/workflows/release-latest-tag.yaml index 0f9cf6bf9b2..501930da84d 100644 --- a/.github/workflows/release-latest-tag.yaml +++ b/.github/workflows/release-latest-tag.yaml @@ -32,8 +32,57 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Move latest to the release tag commit + - name: Verify release tag signature + id: verify-release-tag + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} + with: + script: | + const releaseTag = process.env.RELEASE_TAG ?? ''; + if (!/^v\d+\.\d+\.\d+$/.test(releaseTag)) { + throw new Error(`Refusing to verify non-semver tag: ${releaseTag}`); + } + + const { owner, repo } = context.repo; + const ref = await github.rest.git.getRef({ owner, repo, ref: `tags/${releaseTag}` }); + if (ref.data.object.type !== 'tag') { + throw new Error(`Release tag ${releaseTag} must be annotated`); + } + + const tagObjectSha = ref.data.object.sha; + let tagObject; + for (let attempt = 1; attempt <= 10; attempt += 1) { + ({ data: tagObject } = await github.rest.git.getTag({ + owner, + repo, + tag_sha: tagObjectSha, + })); + if (tagObject.verification?.verified === true) break; + if (attempt < 10) { + core.info(`Waiting for GitHub tag verification (${attempt}/10)`); + await new Promise((resolve) => setTimeout(resolve, 3000)); + } + } + + if (tagObject.tag !== releaseTag) { + throw new Error(`Tag object ${tagObjectSha} names ${tagObject.tag}, expected ${releaseTag}`); + } + if (tagObject.object.type !== 'commit') { + throw new Error(`Release tag ${releaseTag} must point directly to a commit`); + } + if (tagObject.verification?.verified !== true) { + throw new Error( + `Release tag ${releaseTag} is not GitHub-Verified (${tagObject.verification?.reason ?? 'unknown'})`, + ); + } + + core.setOutput('tag_object_sha', tagObjectSha); + core.info(`Verified signed release tag ${releaseTag} (${tagObjectSha})`); + + - name: Move latest to the verified release tag object + env: + EXPECTED_RELEASE_TAG_OBJECT: ${{ steps.verify-release-tag.outputs.tag_object_sha }} + RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref_name }} PUSH_REMOTE_URL: https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git run: scripts/release-latest-tag.sh diff --git a/ci/source-shape-test-budget.json b/ci/source-shape-test-budget.json index 42cb99151f9..34b4796718c 100644 --- a/ci/source-shape-test-budget.json +++ b/ci/source-shape-test-budget.json @@ -466,6 +466,11 @@ "test": "stages the public NVIDIA key for the Model Router's NVIDIA credential", "category": "security" }, + { + "file": "test/release-latest-tag-workflow.test.ts", + "test": "binds latest promotion to the exact GitHub-verified tag object", + "category": "security" + }, { "file": "test/release-lkg-brev-image.test.ts", "test": "keeps LKG dispatch inside the trusted secret boundary (#6772)", diff --git a/scripts/release-cut-tag.sh b/scripts/release-cut-tag.sh index 2885ce7af22..4fab5bd7281 100755 --- a/scripts/release-cut-tag.sh +++ b/scripts/release-cut-tag.sh @@ -21,7 +21,7 @@ while [[ $# -gt 0 ]]; do cat <<'USAGE' Usage: scripts/release-cut-tag.sh --plan PATH --confirm "CONFIRM RELEASE vX.Y.Z " -Creates and pushes only the annotated semver tag described by a release plan. +Creates and pushes only the signed annotated semver tag described by a release plan. USAGE exit 0 ;; @@ -82,7 +82,9 @@ if git ls-remote --exit-code --tags origin "$tag" >/dev/null; then fail "Remote tag already exists: $tag" fi -git tag -a "$tag" "$target" -m "$tag" +# Release tags are immutable once pushed. Sign the tag on the release +# operator's workstation so the private signing key never enters CI. +git tag -s "$tag" "$target" -m "$tag" git push origin "refs/tags/$tag" remote_peeled="$(git ls-remote --tags origin "refs/tags/$tag^{}" | awk '{print $1}')" diff --git a/scripts/release-latest-tag.sh b/scripts/release-latest-tag.sh index 482eb264ef3..14f266ff3e7 100755 --- a/scripts/release-latest-tag.sh +++ b/scripts/release-latest-tag.sh @@ -6,6 +6,7 @@ set -euo pipefail REMOTE_NAME="${REMOTE_NAME:-origin}" RELEASE_TAG="${RELEASE_TAG:?RELEASE_TAG is required}" +EXPECTED_RELEASE_TAG_OBJECT="${EXPECTED_RELEASE_TAG_OBJECT:-}" PUSH_LATEST="${PUSH_LATEST:-1}" PUSH_REMOTE_URL="${PUSH_REMOTE_URL:-$REMOTE_NAME}" @@ -14,22 +15,12 @@ fail() { exit 1 } -ensure_tag_identity() { - if git var GIT_COMMITTER_IDENT >/dev/null 2>&1; then - return - fi - - git config user.name "${RELEASE_TAGGER_NAME:-github-actions[bot]}" - git config user.email "${RELEASE_TAGGER_EMAIL:-41898282+github-actions[bot]@users.noreply.github.com}" - - if ! git var GIT_COMMITTER_IDENT >/dev/null 2>&1; then - fail "Unable to configure a git committer identity for latest tag promotion" - fi -} - if [[ ! "$RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then fail "Refusing to promote non-semver tag: $RELEASE_TAG" fi +if [[ ! "$EXPECTED_RELEASE_TAG_OBJECT" =~ ^[0-9a-f]{40}$ ]]; then + fail "EXPECTED_RELEASE_TAG_OBJECT must be the GitHub-verified tag object SHA" +fi # Force-refresh remote main and tags so local stale tags cannot influence the # release-order, reachability, or annotated-tag checks. @@ -41,6 +32,11 @@ if [[ "$(git cat-file -t "refs/tags/$RELEASE_TAG" 2>/dev/null || true)" != "tag" fail "Refusing to promote $RELEASE_TAG: release tags must be annotated" fi +release_tag_object="$(git rev-parse "refs/tags/$RELEASE_TAG")" +if [[ "$release_tag_object" != "$EXPECTED_RELEASE_TAG_OBJECT" ]]; then + fail "Refusing to promote $RELEASE_TAG: local tag object $release_tag_object does not match GitHub-verified object $EXPECTED_RELEASE_TAG_OBJECT" +fi + release_commit="$(git rev-parse "${RELEASE_TAG}^{commit}")" main_ref="refs/remotes/${REMOTE_NAME}/main" main_commit="$(git rev-parse "$main_ref")" @@ -67,6 +63,7 @@ if [[ "$RELEASE_TAG" != "$latest_remote_semver" ]]; then fail "Refusing to promote $RELEASE_TAG: latest remote semver tag is $latest_remote_semver" fi +latest_object="$(git rev-parse --verify --quiet "refs/tags/latest" || true)" latest_commit="$(git rev-parse --verify --quiet "refs/tags/latest^{commit}" || true)" if [[ -n "$latest_commit" ]] && ! git merge-base --is-ancestor "$latest_commit" "$release_commit"; then fail "Refusing to move latest backward: current latest $latest_commit is not an ancestor of $RELEASE_TAG ($release_commit)" @@ -87,20 +84,31 @@ if [[ -n "$previous_remote_semver" ]]; then fi fi -ensure_tag_identity -git tag -fa latest "$release_commit" -m "latest -> $RELEASE_TAG" +# Point latest at the already signed and GitHub-verified semver tag object. +# Do not mint a second unsigned annotated tag in CI. +git update-ref refs/tags/latest "$release_tag_object" if [[ "$PUSH_LATEST" != "0" ]]; then - git push "$PUSH_REMOTE_URL" refs/tags/latest --force + git push \ + --force-with-lease="refs/tags/latest:${latest_object}" \ + "$PUSH_REMOTE_URL" \ + refs/tags/latest + + remote_latest_object="$(git ls-remote --tags "$REMOTE_NAME" refs/tags/latest | awk '{print $1}')" + if [[ "$remote_latest_object" != "$release_tag_object" ]]; then + fail "Remote latest object $remote_latest_object does not match release tag object $release_tag_object" + fi fi { echo "## Release latest tag" echo echo "- Release tag: \`$RELEASE_TAG\`" + echo "- Release tag object: \`$release_tag_object\`" echo "- Release commit: \`$release_commit\`" echo "- Remote main: \`$main_commit\`" echo "- Latest remote semver: \`$latest_remote_semver\`" + echo "- Previous latest object: \`${latest_object:-none}\`" echo "- Previous latest commit: \`${latest_commit:-none}\`" echo "- Previous semver tag: \`${previous_remote_semver:-none}\`" echo "- Previous semver commit: \`${previous_semver_commit:-none}\`" diff --git a/scripts/release-plan.mts b/scripts/release-plan.mts index 4b68099520e..58ba4e7f987 100644 --- a/scripts/release-plan.mts +++ b/scripts/release-plan.mts @@ -220,7 +220,7 @@ function main(): void { planPath, confirmationPhrase: `CONFIRM RELEASE ${nextTag} ${originMainCommit}`, operations: [ - `create annotated ${nextTag} tag at ${originMainCommit}`, + `create signed annotated ${nextTag} tag at ${originMainCommit}`, `push ${nextTag}`, "wait for release-latest-tag workflow to move latest", "draft release notes from live compare data", diff --git a/scripts/release-wait-latest.sh b/scripts/release-wait-latest.sh index ff4a33ff692..4ecd682c93c 100755 --- a/scripts/release-wait-latest.sh +++ b/scripts/release-wait-latest.sh @@ -72,12 +72,16 @@ remote_tag_commit_or_object() { deadline=$((SECONDS + TIMEOUT_SECS)) latest_peeled="" semver_peeled="" +latest_object="" +semver_object="" while ((SECONDS <= deadline)); do + semver_object="$(git ls-remote --tags origin "refs/tags/$tag" | awk '{print $1}')" semver_peeled="$(git ls-remote --tags origin "refs/tags/$tag^{}" | awk '{print $1}')" + latest_object="$(git ls-remote --tags origin refs/tags/latest | awk '{print $1}')" latest_peeled="$(git ls-remote --tags origin 'refs/tags/latest^{}' | awk '{print $1}')" - if [[ "$semver_peeled" == "$target" && "$latest_peeled" == "$target" ]]; then + if [[ "$semver_peeled" == "$target" && "$latest_peeled" == "$target" && "$latest_object" == "$semver_object" ]]; then break fi @@ -86,6 +90,8 @@ done [[ "$semver_peeled" == "$target" ]] || fail "$tag peeled to $semver_peeled, expected $target" [[ "$latest_peeled" == "$target" ]] || fail "latest peeled to $latest_peeled, expected $target" +[[ -n "$semver_object" ]] || fail "$tag tag object is missing" +[[ "$latest_object" == "$semver_object" ]] || fail "latest tag object $latest_object does not match $tag object $semver_object" lkg_after="$(remote_tag_commit_or_object lkg)" if [[ -n "$lkg_before" && "$lkg_after" != "$lkg_before" ]]; then @@ -96,7 +102,7 @@ if [[ -z "$lkg_before" && -n "$lkg_after" ]]; then fi result_path="$(dirname "$PLAN_PATH")/latest-result.json" -node -e 'const fs=require("fs"); const result={schemaVersion:1,status:"ok",planPath:process.argv[1],planHash:process.argv[2],tag:process.argv[3],targetCommit:process.argv[4],semverPeeledCommit:process.argv[5],latestPeeledCommit:process.argv[6],lkgPeeledCommitBefore:process.argv[7] || null,lkgPeeledCommitAfter:process.argv[8] || null,createdAt:new Date().toISOString()}; fs.writeFileSync(process.argv[9], JSON.stringify(result, null, 2) + "\n");' "$PLAN_PATH" "$plan_hash" "$tag" "$target" "$semver_peeled" "$latest_peeled" "$lkg_before" "$lkg_after" "$result_path" +node -e 'const fs=require("fs"); const result={schemaVersion:1,status:"ok",planPath:process.argv[1],planHash:process.argv[2],tag:process.argv[3],targetCommit:process.argv[4],semverTagObject:process.argv[5],latestTagObject:process.argv[6],semverPeeledCommit:process.argv[7],latestPeeledCommit:process.argv[8],lkgPeeledCommitBefore:process.argv[9] || null,lkgPeeledCommitAfter:process.argv[10] || null,createdAt:new Date().toISOString()}; fs.writeFileSync(process.argv[11], JSON.stringify(result, null, 2) + "\n");' "$PLAN_PATH" "$plan_hash" "$tag" "$target" "$semver_object" "$latest_object" "$semver_peeled" "$latest_peeled" "$lkg_before" "$lkg_after" "$result_path" printf 'release-wait-latest: latest and %s peel to %s\n' "$tag" "$target" printf 'release-wait-latest: result written: %s\n' "$result_path" diff --git a/test/release-latest-tag-workflow.test.ts b/test/release-latest-tag-workflow.test.ts new file mode 100644 index 00000000000..8e28efc86dd --- /dev/null +++ b/test/release-latest-tag-workflow.test.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { readYaml, type WorkflowJob } from "./helpers/e2e-workflow-contract"; + +const AsyncFunction = Object.getPrototypeOf(async () => undefined).constructor as new ( + ...parameters: string[] +) => (...args: unknown[]) => Promise; + +type ReleaseLatestWorkflow = { + jobs: Record; +}; + +const WORKFLOW_PATH = ".github/workflows/release-latest-tag.yaml"; +const RELEASE_TAG = "v0.0.86"; +const TAG_OBJECT_SHA = "a".repeat(40); +const RELEASE_COMMIT = "b".repeat(40); +const workflow = readYaml(WORKFLOW_PATH); +const job = workflow.jobs["update-latest"]; +const verifyStep = job.steps?.find((step) => step.id === "verify-release-tag"); +const moveStep = job.steps?.find( + (step) => step.name === "Move latest to the verified release tag object", +); +const verifyScript = verifyStep?.with?.script; + +function createHarness(verification: { verified: boolean; reason: string }) { + const getRef = vi.fn().mockResolvedValue({ + data: { object: { sha: TAG_OBJECT_SHA, type: "tag" } }, + }); + const getTag = vi.fn().mockResolvedValue({ + data: { + object: { sha: RELEASE_COMMIT, type: "commit" }, + tag: RELEASE_TAG, + verification, + }, + }); + const setOutput = vi.fn(); + const info = vi.fn(); + + return { + core: { info, setOutput }, + getRef, + getTag, + github: { rest: { git: { getRef, getTag } } }, + context: { repo: { owner: "NVIDIA", repo: "NemoClaw" } }, + info, + setOutput, + }; +} + +async function runVerify(harness: ReturnType): Promise { + expect(verifyScript).toEqual(expect.any(String)); + await new AsyncFunction("github", "context", "core", verifyScript as string)( + harness.github, + harness.context, + harness.core, + ); +} + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllEnvs(); +}); + +describe("release latest tag workflow", () => { + // source-shape-contract: security -- Exact verified-object output wiring prevents latest promotion from bypassing GitHub signature verification + it("binds latest promotion to the exact GitHub-verified tag object", () => { + expect(verifyStep?.uses).toBe("actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3"); + expect(moveStep?.env?.EXPECTED_RELEASE_TAG_OBJECT).toBe( + "${{ steps.verify-release-tag.outputs.tag_object_sha }}", + ); + }); + + it("accepts a GitHub-verified signed tag and emits its exact object SHA", async () => { + vi.stubEnv("RELEASE_TAG", RELEASE_TAG); + const harness = createHarness({ verified: true, reason: "valid" }); + + await runVerify(harness); + + expect(harness.getRef).toHaveBeenCalledWith({ + owner: "NVIDIA", + ref: `tags/${RELEASE_TAG}`, + repo: "NemoClaw", + }); + expect(harness.getTag).toHaveBeenCalledWith({ + owner: "NVIDIA", + repo: "NemoClaw", + tag_sha: TAG_OBJECT_SHA, + }); + expect(harness.setOutput).toHaveBeenCalledWith("tag_object_sha", TAG_OBJECT_SHA); + }); + + it("waits for GitHub signature verification to propagate", async () => { + vi.useFakeTimers(); + vi.stubEnv("RELEASE_TAG", RELEASE_TAG); + const harness = createHarness({ verified: true, reason: "valid" }); + harness.getTag + .mockResolvedValueOnce({ + data: { + object: { sha: RELEASE_COMMIT, type: "commit" }, + tag: RELEASE_TAG, + verification: { verified: false, reason: "unsigned" }, + }, + }) + .mockResolvedValue({ + data: { + object: { sha: RELEASE_COMMIT, type: "commit" }, + tag: RELEASE_TAG, + verification: { verified: true, reason: "valid" }, + }, + }); + + const verification = runVerify(harness); + await vi.runAllTimersAsync(); + await verification; + + expect(harness.getTag).toHaveBeenCalledTimes(2); + expect(harness.setOutput).toHaveBeenCalledWith("tag_object_sha", TAG_OBJECT_SHA); + }); + + it("rejects a tag that GitHub never verifies", async () => { + vi.useFakeTimers(); + vi.stubEnv("RELEASE_TAG", RELEASE_TAG); + const harness = createHarness({ verified: false, reason: "unsigned" }); + + const verification = expect(runVerify(harness)).rejects.toThrow( + `Release tag ${RELEASE_TAG} is not GitHub-Verified (unsigned)`, + ); + await vi.runAllTimersAsync(); + await verification; + + expect(harness.getTag).toHaveBeenCalledTimes(10); + expect(harness.setOutput).not.toHaveBeenCalled(); + }); +}); diff --git a/test/release-latest-tag.test.ts b/test/release-latest-tag.test.ts index a84e0f00bb7..37304e11f10 100644 --- a/test/release-latest-tag.test.ts +++ b/test/release-latest-tag.test.ts @@ -70,12 +70,27 @@ function createFixture(): Fixture { const remote = path.join(root, "remote.git"); const work = path.join(root, "work"); const summary = path.join(root, "summary.md"); + const signingKey = path.join(root, "release-signing-key"); run(root, ["git", "init", "--bare", remote]); + run(root, [ + "ssh-keygen", + "-q", + "-t", + "ed25519", + "-N", + "", + "-C", + "release-test@example.com", + "-f", + signingKey, + ]); fs.mkdirSync(work); run(work, ["git", "init"]); run(work, ["git", "config", "user.name", "Release Test"]); run(work, ["git", "config", "user.email", "release-test@example.com"]); + run(work, ["git", "config", "gpg.format", "ssh"]); + run(work, ["git", "config", "user.signingkey", signingKey]); fs.writeFileSync(path.join(work, "file.txt"), "initial\n"); run(work, ["git", "add", "file.txt"]); run(work, ["git", "commit", "-m", "initial"]); @@ -103,12 +118,23 @@ function pushTag(fixture: Fixture, tag: string, target = "HEAD", annotated = tru run(fixture.work, ["git", "push", "origin", `refs/tags/${tag}`]); } -function runReleaseLatest(fixture: Fixture, releaseTag: string): ReturnType { +function localTagObject(fixture: Fixture, tag: string): string { + return run(fixture.work, ["git", "rev-parse", `refs/tags/${tag}`], { + allowFailure: true, + }).trim(); +} + +function runReleaseLatest( + fixture: Fixture, + releaseTag: string, + expectedReleaseTagObject = localTagObject(fixture, releaseTag) || "0".repeat(40), +): ReturnType { return spawnSync("bash", [latestScriptPath], { cwd: fixture.work, encoding: "utf8", env: testEnv({ RELEASE_TAG: releaseTag, + EXPECTED_RELEASE_TAG_OBJECT: expectedReleaseTagObject, REMOTE_NAME: "origin", GITHUB_STEP_SUMMARY: fixture.summary, }), @@ -134,6 +160,7 @@ function runReleaseLatestWithoutIdentity( GITHUB_STEP_SUMMARY: fixture.summary, HOME: home, RELEASE_TAG: releaseTag, + EXPECTED_RELEASE_TAG_OBJECT: localTagObject(fixture, releaseTag), REMOTE_NAME: "origin", XDG_CONFIG_HOME: xdgConfigHome, }); @@ -165,6 +192,14 @@ function remoteCommit(fixture: Fixture, ref: string): string { return run(fixture.root, ["git", "--git-dir", fixture.remote, "rev-parse", `${ref}^{}`]).trim(); } +function remoteObject(fixture: Fixture, ref: string): string { + return run(fixture.root, ["git", "--git-dir", fixture.remote, "rev-parse", ref]).trim(); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} + function readJson(filePath: string): any { return JSON.parse(fs.readFileSync(filePath, "utf8")); } @@ -185,6 +220,7 @@ function createPlan( expect(plan.previousTag).toBe("v0.0.1"); expect(plan.nextTag).toBe("v0.0.2"); expect(plan.originMainCommit).toBe(releaseCommit); + expect(plan.operations).toContain(`create signed annotated v0.0.2 tag at ${releaseCommit}`); expect(plan.confirmationPhrase).toBe(`CONFIRM RELEASE v0.0.2 ${releaseCommit}`); return { plan, result }; } @@ -224,6 +260,13 @@ afterEach(() => { }); describe("release-latest-tag.sh", () => { + it("advertises that release cuts create signed annotated tags", () => { + const result = runScript(repoRoot, ["bash", cutScriptPath, "--help"]); + + expect(result.status).toBe(0); + expect(result.stdout).toContain("signed annotated semver tag"); + }); + it("promotes latest to the newest annotated semver tag without touching lkg", () => { const fixture = createFixture(); pushTag(fixture, "lkg", fixture.firstCommit); @@ -234,11 +277,14 @@ describe("release-latest-tag.sh", () => { expect(result.status).toBe(0); expect(remoteCommit(fixture, "refs/tags/latest")).toBe(releaseCommit); + expect(remoteObject(fixture, "refs/tags/latest")).toBe( + remoteObject(fixture, "refs/tags/v0.0.1"), + ); expect(remoteCommit(fixture, "refs/tags/lkg")).toBe(fixture.firstCommit); expect(fs.readFileSync(fixture.summary, "utf8")).toContain("Not touched: `lkg`"); }); - it("configures a bot identity when promoting latest on a runner without git identity", () => { + it("promotes the existing tag object without requiring a runner git identity", () => { const fixture = createFixture(); const releaseCommit = commit(fixture, "release commit"); pushTag(fixture, "v0.0.1"); @@ -249,11 +295,66 @@ describe("release-latest-tag.sh", () => { expect(result.status).toBe(0); expect(remoteCommit(fixture, "refs/tags/latest")).toBe(releaseCommit); - expect(run(fixture.work, ["git", "config", "--local", "user.name"]).trim()).toBe( - "github-actions[bot]", + expect(remoteObject(fixture, "refs/tags/latest")).toBe( + remoteObject(fixture, "refs/tags/v0.0.1"), ); - expect(run(fixture.work, ["git", "config", "--local", "user.email"]).trim()).toBe( - "41898282+github-actions[bot]@users.noreply.github.com", + expect( + run(fixture.work, ["git", "config", "--local", "user.name"], { allowFailure: true }), + ).toBe(""); + expect( + run(fixture.work, ["git", "config", "--local", "user.email"], { allowFailure: true }), + ).toBe(""); + }); + + it("rejects a release tag object that differs from the GitHub-verified object", () => { + const fixture = createFixture(); + pushTag(fixture, "v0.0.1"); + + const result = runReleaseLatest(fixture, "v0.0.1", "f".repeat(40)); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("does not match GitHub-verified object"); + expect( + runScript(fixture.root, [ + "git", + "--git-dir", + fixture.remote, + "show-ref", + "--verify", + "--quiet", + "refs/tags/latest", + ]).status, + ).not.toBe(0); + }); + + it("does not overwrite a concurrent remote latest update", () => { + const fixture = createFixture(); + pushTag(fixture, "v0.0.1", fixture.firstCommit); + expect(runReleaseLatest(fixture, "v0.0.1").status).toBe(0); + const releaseCommit = commit(fixture, "next release commit"); + pushTag(fixture, "v0.0.2", releaseCommit); + pushTag(fixture, "concurrent-latest", fixture.firstCommit); + const concurrentObject = remoteObject(fixture, "refs/tags/concurrent-latest"); + const hookPath = path.join(fixture.work, ".git", "hooks", "pre-push"); + fs.writeFileSync( + hookPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + `git --git-dir=${shellQuote(fixture.remote)} update-ref refs/tags/latest ${concurrentObject}`, + "", + ].join("\n"), + "utf8", + ); + fs.chmodSync(hookPath, 0o755); + + const result = runReleaseLatest(fixture, "v0.0.2"); + + expect(result.status).not.toBe(0); + expect(`${result.stdout}\n${result.stderr}`).toContain("cannot lock ref 'refs/tags/latest'"); + expect(remoteObject(fixture, "refs/tags/latest")).toBe(concurrentObject); + expect(remoteObject(fixture, "refs/tags/latest")).not.toBe( + remoteObject(fixture, "refs/tags/v0.0.2"), ); }); @@ -355,6 +456,10 @@ describe("release-latest-tag.sh", () => { expect(cutResult.status).toBe(0); expect(remoteCommit(fixture, "refs/tags/v0.0.2")).toBe(releaseCommit); + const releaseTagObject = remoteObject(fixture, "refs/tags/v0.0.2"); + expect( + run(fixture.root, ["git", "--git-dir", fixture.remote, "cat-file", "-p", releaseTagObject]), + ).toContain("-----BEGIN SSH SIGNATURE-----"); expect(readJson(path.join(fixture.root, "release", "cut-result.json"))).toMatchObject({ tag: "v0.0.2", targetCommit: releaseCommit, @@ -371,12 +476,30 @@ describe("release-latest-tag.sh", () => { expect(readJson(path.join(fixture.root, "release", "latest-result.json"))).toMatchObject({ tag: "v0.0.2", targetCommit: releaseCommit, + semverTagObject: releaseTagObject, + latestTagObject: releaseTagObject, latestPeeledCommit: releaseCommit, lkgPeeledCommitBefore: fixture.firstCommit, lkgPeeledCommitAfter: fixture.firstCommit, }); }); + it("rejects a distinct latest tag object even when it peels to the release commit", () => { + const fixture = createFixture(); + pushTag(fixture, "v0.0.1", fixture.firstCommit); + const releaseCommit = commit(fixture, "planned release commit"); + const planPath = path.join(fixture.root, "release", "plan.json"); + const { plan } = createPlan(fixture, planPath, releaseCommit); + expect(cutFromPlan(fixture, planPath, plan.confirmationPhrase).status).toBe(0); + pushTag(fixture, "latest", releaseCommit); + + const waitResult = waitForLatest(fixture, planPath); + + expect(waitResult.status).not.toBe(0); + expect(waitResult.stderr).toContain("latest tag object"); + expect(waitResult.stderr).toContain("does not match v0.0.2 object"); + }); + it("rejects a tampered release plan before cutting the tag", () => { const fixture = createFixture(); pushTag(fixture, "v0.0.1", fixture.firstCommit);