|
| 1 | +import assert from "node:assert/strict"; |
| 2 | +import { execFileSync, spawnSync } from "node:child_process"; |
| 3 | +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; |
| 4 | +import { tmpdir } from "node:os"; |
| 5 | +import { join } from "node:path"; |
| 6 | +import test from "node:test"; |
| 7 | +import { parse } from "yaml"; |
| 8 | + |
| 9 | +const workflow = readFileSync(new URL("../.github/workflows/publish.yml", import.meta.url), "utf8"); |
| 10 | +const config = parse(workflow); |
| 11 | +const publish = config.jobs.publish; |
| 12 | +const checkout = publish.steps.find((step) => step.uses?.startsWith("actions/checkout@")); |
| 13 | +const checkoutGuard = publish.steps.find( |
| 14 | + (step) => step.name === "Verify immutable release checkout", |
| 15 | +); |
| 16 | +const createReleaseTag = publish.steps.find((step) => step.name === "Create release tag"); |
| 17 | + |
| 18 | +const normalizeExpression = (expression) => expression.replace(/\s+/g, " ").trim(); |
| 19 | + |
| 20 | +function git(cwd, ...args) { |
| 21 | + return execFileSync("git", args, { |
| 22 | + cwd, |
| 23 | + encoding: "utf8", |
| 24 | + stdio: "pipe", |
| 25 | + timeout: 5_000, |
| 26 | + }).trim(); |
| 27 | +} |
| 28 | + |
| 29 | +function runCreateReleaseTag(cwd, version) { |
| 30 | + return spawnSync("bash", ["-euo", "pipefail", "-c", createReleaseTag.run], { |
| 31 | + cwd, |
| 32 | + encoding: "utf8", |
| 33 | + env: { ...process.env, VERSION: version }, |
| 34 | + timeout: 5_000, |
| 35 | + }); |
| 36 | +} |
| 37 | + |
| 38 | +test("stable publishing has one reviewed immutable event path", () => { |
| 39 | + assert.deepEqual(config.on.push.tags, ["v*-*"]); |
| 40 | + assert.equal(config.on.workflow_dispatch, undefined); |
| 41 | + assert.equal( |
| 42 | + normalizeExpression(publish.if), |
| 43 | + "github.event_name == 'push' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/v'))", |
| 44 | + ); |
| 45 | + assert.equal( |
| 46 | + normalizeExpression(publish.env.EXPECTED_RELEASE_SHA), |
| 47 | + "${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.sha }}", |
| 48 | + ); |
| 49 | + assert.equal(checkout.with.ref, "${{ env.EXPECTED_RELEASE_SHA }}"); |
| 50 | +}); |
| 51 | + |
| 52 | +test("the executable checkout guard cannot be conditionally disabled", () => { |
| 53 | + assert.ok(checkoutGuard); |
| 54 | + assert.equal(checkoutGuard.if, undefined); |
| 55 | + assert.equal(checkoutGuard["continue-on-error"], undefined); |
| 56 | + assert.equal( |
| 57 | + checkoutGuard.run.trim(), |
| 58 | + [ |
| 59 | + 'ACTUAL_SHA="$(git rev-parse HEAD)"', |
| 60 | + 'EXPECTED_COMMIT_SHA="$(git rev-parse "${EXPECTED_RELEASE_SHA}^{commit}")"', |
| 61 | + 'if [ "$ACTUAL_SHA" != "$EXPECTED_COMMIT_SHA" ]; then', |
| 62 | + ' echo "::error::Expected release commit $EXPECTED_COMMIT_SHA, checked out $ACTUAL_SHA"', |
| 63 | + " exit 1", |
| 64 | + "fi", |
| 65 | + ].join("\n"), |
| 66 | + ); |
| 67 | +}); |
| 68 | + |
| 69 | +test("stable release tag recovery is idempotent and immutable", () => { |
| 70 | + assert.ok(createReleaseTag); |
| 71 | + assert.equal(createReleaseTag.if, "github.event_name == 'pull_request'"); |
| 72 | + assert.equal( |
| 73 | + createReleaseTag.run.trim(), |
| 74 | + [ |
| 75 | + 'TAG="v$VERSION"', |
| 76 | + 'EXPECTED_TAG_SHA="$(git rev-parse HEAD)"', |
| 77 | + "", |
| 78 | + "verify_remote_tag() {", |
| 79 | + ' git fetch --force --no-tags origin "+refs/tags/$TAG:refs/tags/$TAG"', |
| 80 | + ' ACTUAL_TAG_SHA="$(git rev-parse "refs/tags/$TAG^{commit}")"', |
| 81 | + ' if [ "$ACTUAL_TAG_SHA" != "$EXPECTED_TAG_SHA" ]; then', |
| 82 | + ' echo "::error::Release tag $TAG points to $ACTUAL_TAG_SHA, expected $EXPECTED_TAG_SHA"', |
| 83 | + " exit 1", |
| 84 | + " fi", |
| 85 | + ' echo "Release tag $TAG already exists at the expected commit — skipping"', |
| 86 | + "}", |
| 87 | + "", |
| 88 | + 'if [ -n "$(git ls-remote --refs origin "refs/tags/$TAG")" ]; then', |
| 89 | + " verify_remote_tag", |
| 90 | + "else", |
| 91 | + ' git tag --no-sign "$TAG" "$EXPECTED_TAG_SHA"', |
| 92 | + ' if ! git push origin "refs/tags/$TAG"; then', |
| 93 | + " # A concurrent retry may have created the tag after ls-remote.", |
| 94 | + ' git tag -d "$TAG"', |
| 95 | + " verify_remote_tag", |
| 96 | + " fi", |
| 97 | + "fi", |
| 98 | + ].join("\n"), |
| 99 | + ); |
| 100 | +}); |
| 101 | + |
| 102 | +test("stable release tag creation survives retries and rejects a mismatched commit", () => { |
| 103 | + const root = mkdtempSync(join(tmpdir(), "hyperframes-release-tag-test-")); |
| 104 | + const origin = join(root, "origin.git"); |
| 105 | + const checkout = join(root, "checkout"); |
| 106 | + |
| 107 | + try { |
| 108 | + execFileSync("git", ["init", "--bare", origin], { stdio: "pipe", timeout: 5_000 }); |
| 109 | + execFileSync("git", ["init", checkout], { stdio: "pipe", timeout: 5_000 }); |
| 110 | + git(checkout, "config", "user.name", "HyperFrames Test"); |
| 111 | + git(checkout, "config", "user.email", "test@hyperframes.invalid"); |
| 112 | + git(checkout, "commit", "--allow-empty", "-m", "release commit"); |
| 113 | + git(checkout, "branch", "-M", "main"); |
| 114 | + git(checkout, "remote", "add", "origin", origin); |
| 115 | + git(checkout, "push", "-u", "origin", "main"); |
| 116 | + |
| 117 | + const releaseSha = git(checkout, "rev-parse", "HEAD"); |
| 118 | + const firstRun = runCreateReleaseTag(checkout, "9.8.7"); |
| 119 | + assert.equal(firstRun.status, 0, `${firstRun.stdout}\n${firstRun.stderr}`); |
| 120 | + assert.equal(git(checkout, "rev-parse", "refs/tags/v9.8.7^{commit}"), releaseSha); |
| 121 | + |
| 122 | + const retry = runCreateReleaseTag(checkout, "9.8.7"); |
| 123 | + assert.equal(retry.status, 0, `${retry.stdout}\n${retry.stderr}`); |
| 124 | + assert.match(retry.stdout, /already exists at the expected commit/); |
| 125 | + |
| 126 | + git(checkout, "commit", "--allow-empty", "-m", "different commit"); |
| 127 | + const mismatch = runCreateReleaseTag(checkout, "9.8.7"); |
| 128 | + assert.equal(mismatch.status, 1, `${mismatch.stdout}\n${mismatch.stderr}`); |
| 129 | + assert.match(mismatch.stdout, /points to .* expected/); |
| 130 | + } finally { |
| 131 | + rmSync(root, { recursive: true, force: true }); |
| 132 | + } |
| 133 | +}); |
0 commit comments