From 6c6ba7478fd6e075c5b8c8caaf45904d20855fc7 Mon Sep 17 00:00:00 2001 From: Altay Date: Sat, 5 Sep 2026 13:59:22 +0300 Subject: [PATCH] ci: retire recovery and make release writeback atomic --- .github/workflows/release.yml | 51 +---- .releaserc.json | 8 +- apps/cli/scripts/recover-0.6.2.ts | 201 -------------------- apps/cli/scripts/release-commit.ts | 89 +++++++++ apps/cli/test/recovery.test.ts | 267 --------------------------- apps/cli/test/release-commit.test.ts | 141 ++++++++++++++ docs/releasing.md | 54 ++---- 7 files changed, 252 insertions(+), 559 deletions(-) delete mode 100644 apps/cli/scripts/recover-0.6.2.ts create mode 100644 apps/cli/scripts/release-commit.ts delete mode 100644 apps/cli/test/recovery.test.ts create mode 100644 apps/cli/test/release-commit.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index db5deaf..93d211b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,6 @@ name: Release # then `npm trust github …` — see docs/releasing.md. CI owns every later release. on: - workflow_dispatch: push: branches: [main] @@ -15,7 +14,7 @@ permissions: {} jobs: verify: - if: ${{ github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') }} + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 permissions: @@ -40,16 +39,13 @@ jobs: - run: pnpm exec vp run ready scan: - if: ${{ github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') }} + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} permissions: contents: read uses: uinaf/.github/.github/workflows/scan.yml@main release: - if: >- - ${{ always() && !cancelled() && - ((github.event_name == 'push' && needs.verify.result == 'success' && needs.scan.result == 'success') || - (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main')) }} + if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} needs: [verify, scan] # npm provenance requires a GitHub-hosted runner. runs-on: ubuntu-24.04 @@ -77,20 +73,7 @@ jobs: cache: false run-install: | - args: ["--frozen-lockfile"] - - name: Validate the existing CLI 0.6.2 release state - if: github.event_name == 'workflow_dispatch' - id: recovery - env: - GH_TOKEN: ${{ github.token }} - run: node apps/cli/scripts/recover-0.6.2.ts preflight - - name: Verify and pack the recovery build - if: github.event_name == 'workflow_dispatch' - run: | - pnpm run verify - cd apps/cli - npm pack --ignore-scripts - name: Build and pack smoke - if: github.event_name == 'push' run: | set -euo pipefail pnpm --filter @uinaf/attach-cli build @@ -111,12 +94,10 @@ jobs: homebrew-tap permission-contents: write - name: Authorize release writes - if: github.event_name == 'push' env: GH_TOKEN: ${{ steps.release-bot.outputs.token }} run: gh auth setup-git - id: semantic - if: github.event_name == 'push' uses: cycjimmy/semantic-release-action@b12c8f6015dc215fe37bc154d4ad456dd3833c90 # v6.0.0 with: semantic_version: 25.0.3 @@ -124,41 +105,17 @@ jobs: @semantic-release/commit-analyzer@13.0.1 @semantic-release/release-notes-generator@14.1.1 @semantic-release/npm@13.1.5 - @jno21/semantic-release-github-commit@1.0.1 @semantic-release/github@12.0.8 conventional-changelog-conventionalcommits@9.3.1 env: GITHUB_TOKEN: ${{ steps.release-bot.outputs.token }} GH_TOKEN: ${{ steps.release-bot.outputs.token }} - - name: Publish the missing npm package with OIDC - if: github.event_name == 'workflow_dispatch' && steps.recovery.outputs.publish == 'true' - working-directory: apps/cli - run: npm publish --ignore-scripts --access public --provenance - - name: Verify published package integrity and provenance - if: github.event_name == 'workflow_dispatch' - env: - GH_TOKEN: ${{ github.token }} - run: node apps/cli/scripts/recover-0.6.2.ts published - - name: Create the missing GitHub release without changing its tag - if: github.event_name == 'workflow_dispatch' && steps.recovery.outputs.release == 'true' - env: - GH_TOKEN: ${{ steps.release-bot.outputs.token }} - RECOVERY_SHA: ${{ github.sha }} - run: | - gh release create cli-v0.6.2 --verify-tag --title cli-v0.6.2 --generate-notes --notes-start-tag cli-v0.6.1 \ - --notes "Recovered npm publication from build $RECOVERY_SHA. Package inputs match the unchanged tag cli-v0.6.2 at its signed release commit; provenance and npm gitHead identify the recovery build." - - name: Verify recovered release parity - if: github.event_name == 'workflow_dispatch' - env: - GH_TOKEN: ${{ github.token }} - run: node apps/cli/scripts/recover-0.6.2.ts complete - - name: Resolve release target id: release-target env: GH_TOKEN: ${{ steps.release-bot.outputs.token }} - NEW_RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && 'cli-v0.6.2' || steps.semantic.outputs.new_release_git_tag }} + NEW_RELEASE_TAG: ${{ steps.semantic.outputs.new_release_git_tag }} run: | set -euo pipefail tag="$NEW_RELEASE_TAG" diff --git a/.releaserc.json b/.releaserc.json index d355425..21f1c98 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -20,13 +20,7 @@ "pkgRoot": "apps/cli" } ], - [ - "@jno21/semantic-release-github-commit", - { - "files": ["apps/cli/package.json"], - "commitMessage": "chore(release): ${nextRelease.version} [skip ci]" - } - ], + "./apps/cli/scripts/release-commit.ts", [ "@semantic-release/github", { diff --git a/apps/cli/scripts/recover-0.6.2.ts b/apps/cli/scripts/recover-0.6.2.ts deleted file mode 100644 index 099c141..0000000 --- a/apps/cli/scripts/recover-0.6.2.ts +++ /dev/null @@ -1,201 +0,0 @@ -// Fixed recovery for the tag created before npm rejected the self-hosted runner. -import assert from "node:assert/strict"; -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { appendFileSync, readFileSync } from "node:fs"; - -const tag = "cli-v0.6.2"; -const sha = "7a0c78d311323ae0677389405c55f9d04c920928"; -const version = "0.6.2"; -const repo = "uinaf/attach"; - -function record(value: unknown): Record { - assert(value !== null && typeof value === "object" && !Array.isArray(value), "Expected object"); - return Object.fromEntries(Object.entries(value)); -} - -export async function lookup(url: string, token?: string): Promise | null> { - const response = await fetch(url, { - headers: token ? { Authorization: `Bearer ${token}` } : {}, - signal: AbortSignal.timeout(30_000), - }); - if (response.status === 404) return null; - assert(response.ok, `Lookup failed: HTTP ${response.status} at ${url}`); - return record(await response.json()); -} - -export async function lookupPublished(url: string): Promise | null> { - // Registry attestations can remain 404 briefly after npm accepts publication. - for (let attempt = 0; attempt < 12; attempt++) { - const result = await lookup(url); - if (result !== null || attempt === 11) return result; - await new Promise((resolve) => setTimeout(resolve, 5_000)); - } - return null; -} - -export function checkInputs(files: string[]): void { - const recoveryFiles = new Set([ - ".github/workflows/release.yml", - "docs/releasing.md", - "apps/cli/scripts/recover-0.6.2.ts", - "apps/cli/test/recovery.test.ts", - ]); - for (const file of files) assert(recoveryFiles.has(file), `Package input changed: ${file}`); -} - -export function checkPackage( - pkg: Record, - integrity?: string, - buildSha?: string, -): void { - assert.equal(pkg.name, "@uinaf/attach-cli"); - assert.equal(pkg.version, version); - if (buildSha) assert.equal(pkg.gitHead, buildSha, "npm gitHead differs from the recovery build"); - const dist = record(pkg.dist); - assert.equal(typeof dist.integrity, "string"); - if (integrity) - assert.equal(dist.integrity, integrity, "Published tarball differs from verified build"); - const attestations = record(dist.attestations); - assert.equal(record(attestations.provenance).predicateType, "https://slsa.dev/provenance/v1"); -} - -// npm validates the signed bundle at ingestion; check its registry-served claims here. -export function checkProvenance( - response: Record, - integrity: string, - buildSha: string, -): void { - assert(Array.isArray(response.attestations), "Missing npm attestations"); - const entries = response.attestations - .map(record) - .filter((entry) => entry.predicateType === "https://slsa.dev/provenance/v1"); - assert.equal(entries.length, 1, "Expected one npm provenance statement"); - const envelope = record(record(entries[0]?.bundle).dsseEnvelope); - assert.equal(envelope.payloadType, "application/vnd.in-toto+json"); - assert.equal(typeof envelope.payload, "string"); - const statement = record( - JSON.parse(Buffer.from(String(envelope.payload), "base64").toString("utf8")), - ); - assert.equal(statement._type, "https://in-toto.io/Statement/v1"); - assert.equal(statement.predicateType, "https://slsa.dev/provenance/v1"); - assert.match(integrity, /^sha512-[A-Za-z0-9+/]{86}==$/); - assert.deepEqual( - statement.subject, - [ - { - name: "pkg:npm/%40uinaf/attach-cli@0.6.2", - digest: { sha512: Buffer.from(integrity.slice(7), "base64").toString("hex") }, - }, - ], - "Provenance subject differs from the npm artifact", - ); - const predicate = record(statement.predicate); - const definition = record(predicate.buildDefinition); - assert.equal( - definition.buildType, - "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", - ); - assert.deepEqual( - record(definition.externalParameters).workflow, - { - ref: "refs/heads/main", - repository: `https://github.com/${repo}`, - path: ".github/workflows/release.yml", - }, - "Provenance workflow differs from recovery", - ); - assert.equal( - record(record(definition.internalParameters).github).event_name, - "workflow_dispatch", - ); - assert.deepEqual( - definition.resolvedDependencies, - [ - { - uri: `git+https://github.com/${repo}@refs/heads/main`, - digest: { gitCommit: buildSha }, - }, - ], - "Provenance commit differs from the event commit", - ); - assert.equal( - record(record(predicate.runDetails).builder).id, - "https://github.com/actions/runner/github-hosted", - ); -} - -export function checkRelease(release: Record): void { - assert.equal(release.tag_name, tag); - assert.equal(release.draft, false); - assert.equal(release.prerelease, false); - assert.equal(release.immutable, true); -} - -async function main(): Promise { - assert.equal(process.env.GITHUB_REPOSITORY, repo); - assert.equal(process.env.GITHUB_REF, "refs/heads/main"); - const buildSha = process.env.GITHUB_SHA; - assert(buildSha && /^[a-f0-9]{40}$/.test(buildSha), "Expected event commit SHA"); - const git = (...args: string[]) => execFileSync("git", args, { encoding: "utf8" }).trim(); - assert.equal(git("rev-parse", "HEAD"), buildSha, "Checkout differs from the event commit"); - git("merge-base", "--is-ancestor", sha, buildSha); - checkInputs(git("diff", "--name-only", "-z", sha, buildSha).split("\0").filter(Boolean)); - git("diff", "--exit-code", "HEAD", "--"); - const token = process.env.GH_TOKEN; - assert(token, "Read token required for exact GitHub lookups"); - const github = (path: string) => lookup(`https://api.github.com/repos/${repo}/${path}`, token); - const ref = await github(`git/ref/tags/${tag}`); - assert(ref, "Release tag missing"); - assert.equal(record(ref.object).sha, sha); - assert.equal(record(ref.object).type, "commit"); - const commit = await github(`commits/${sha}`); - assert(commit); - assert.equal(record(record(commit.commit).verification).verified, true); - const comparison = await github(`compare/${buildSha}...main`); - assert(comparison); - assert( - ["ahead", "identical"].includes(String(comparison.status)), - "Recovery commit is not an ancestor of main", - ); - const file = await github(`contents/apps/cli/package.json?ref=${sha}`); - assert(file && typeof file.content === "string"); - const manifest = record(JSON.parse(Buffer.from(file.content, "base64").toString("utf8"))); - assert.equal(manifest.name, "@uinaf/attach-cli"); - assert.equal(manifest.version, version); - - const mode = process.argv[2]; - assert(mode === "preflight" || mode === "published" || mode === "complete"); - const registry = mode === "published" ? lookupPublished : lookup; - const pkg = await registry("https://registry.npmjs.org/@uinaf%2fattach-cli/0.6.2"); - const release = await github(`releases/tags/${tag}`); - if (pkg) { - checkPackage(pkg, undefined, buildSha); - const attestation = await registry( - "https://registry.npmjs.org/-/npm/v1/attestations/@uinaf%2fattach-cli@0.6.2", - ); - assert(attestation, "npm provenance bundle is missing"); - checkProvenance(attestation, String(record(pkg.dist).integrity), buildSha); - } - if (release) checkRelease(release); - if (mode === "preflight") { - assert(process.env.GITHUB_OUTPUT); - appendFileSync( - process.env.GITHUB_OUTPUT, - `publish=${pkg === null}\nrelease=${release === null}\n`, - ); - return; - } - assert(pkg, "npm package is still missing"); - const tarball = readFileSync("apps/cli/uinaf-attach-cli-0.6.2.tgz"); - checkPackage(pkg, `sha512-${createHash("sha512").update(tarball).digest("base64")}`, buildSha); - if (mode === "complete") { - assert(release, "GitHub release is still missing"); - checkRelease(release); - console.log( - "cli-v0.6.2: unchanged package inputs, recovery build integrity and immutable release verified", - ); - } -} - -if (import.meta.main) await main(); diff --git a/apps/cli/scripts/release-commit.ts b/apps/cli/scripts/release-commit.ts new file mode 100644 index 0000000..e4c0435 --- /dev/null +++ b/apps/cli/scripts/release-commit.ts @@ -0,0 +1,89 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; +import assert from "node:assert/strict"; + +interface Context { + cwd: string; + env: NodeJS.ProcessEnv; + nextRelease: { version: string; gitHead: string }; +} + +function record(value: unknown): Record { + assert(value !== null && typeof value === "object" && !Array.isArray(value), "Expected object"); + return Object.fromEntries(Object.entries(value)); +} +function oid(value: unknown): string { + assert(typeof value === "string" && /^[a-f0-9]{40}$/.test(value), "Expected commit SHA"); + return value; +} + +// npm's prepare runs first. Only its version edit may enter the signed commit. +export async function prepare(_config: unknown, context: Context): Promise { + const { cwd, env, nextRelease } = context; + const expected = oid(env.GITHUB_SHA); + const repository = env.GITHUB_REPOSITORY; + assert.equal(repository, "uinaf/attach"); + const token = env.GITHUB_TOKEN ?? env.GH_TOKEN; + assert(token, "Release token required"); + const git = (...args: string[]) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + if (git("rev-parse", "HEAD") !== expected || nextRelease.gitHead !== expected) + throw new Error("release checkout must match the verified event commit"); + if (git("diff", "--name-only", expected, "--", ".", ":(exclude)apps/cli/package.json")) + throw new Error("release preparation changed files other than apps/cli/package.json"); + const before = record(JSON.parse(git("show", `${expected}:apps/cli/package.json`))); + const content = fs.readFileSync(path.join(cwd, "apps/cli/package.json")); + const prepared = record(JSON.parse(content.toString("utf8"))); + if (!isDeepStrictEqual(prepared, { ...before, version: nextRelease.version })) + throw new Error("release preparation must change only the package version"); + + // GitHub signs this commit and rejects a moved branch atomically. A preflight + // against main would leave a race between reading its head and writing it. + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + signal: AbortSignal.timeout(30_000), + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + query: `mutation($input: CreateCommitOnBranchInput!) { + createCommitOnBranch(input: $input) { commit { oid } } + }`, + variables: { + input: { + branch: { repositoryNameWithOwner: repository, branchName: "main" }, + expectedHeadOid: expected, + message: { headline: `chore(release): ${nextRelease.version} [skip ci]` }, + fileChanges: { + additions: [{ path: "apps/cli/package.json", contents: content.toString("base64") }], + }, + }, + }, + }), + }); + if (!response.ok) throw new Error(`release writeback failed: GitHub HTTP ${response.status}`); + const result = record(await response.json()); + if (result.errors !== undefined) { + assert(Array.isArray(result.errors), "Expected GraphQL errors array"); + if (result.errors.length) { + const messages = result.errors.map((error) => { + const message = record(error).message; + assert.equal(typeof message, "string"); + return message; + }); + throw new Error(`release writeback rejected: ${messages.join("; ")}`); + } + } + const committed = oid(record(record(record(result.data).createCommitOnBranch).commit).oid); + + // main may advance after the mutation. Fetch and tag the returned commit, + // never the moving branch. A failure here leaves writeback without a release. + git("fetch", "origin", committed); + if (git("show", "-s", "--format=%P", committed) !== expected) + throw new Error("release writeback has an unexpected parent"); + if (git("diff", "--name-only", expected, committed, "--", ".", ":(exclude)apps/cli/package.json")) + throw new Error("release writeback changed source outside apps/cli/package.json"); + if (git("show", `${committed}:apps/cli/package.json`) !== content.toString("utf8").trim()) + throw new Error("release writeback does not match the prepared package"); + git("reset", "--hard", committed); + nextRelease.gitHead = committed; +} diff --git a/apps/cli/test/recovery.test.ts b/apps/cli/test/recovery.test.ts deleted file mode 100644 index 7e3c74b..0000000 --- a/apps/cli/test/recovery.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { - checkInputs, - checkPackage, - checkProvenance, - checkRelease, - lookup, - lookupPublished, -} from "../scripts/recover-0.6.2.ts"; - -afterEach(() => { - vi.unstubAllGlobals(); - vi.useRealTimers(); -}); - -describe("post-publication registry visibility", () => { - it("waits five seconds between confirmed 404s and stops when visible", async () => { - vi.useFakeTimers(); - const fetch = vi - .fn() - .mockResolvedValueOnce(new Response(null, { status: 404 })) - .mockResolvedValueOnce(new Response(null, { status: 404 })) - .mockResolvedValueOnce(Response.json({ version: "0.6.2" })); - vi.stubGlobal("fetch", fetch); - const result = lookupPublished("https://registry.npmjs.org/fixture"); - await vi.advanceTimersByTimeAsync(4_999); - expect(fetch).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(5_001); - await expect(result).resolves.toEqual({ version: "0.6.2" }); - expect(fetch).toHaveBeenCalledTimes(3); - }); - - it("stops after twelve confirmed missing responses", async () => { - vi.useFakeTimers(); - const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 404 })); - vi.stubGlobal("fetch", fetch); - const result = lookupPublished("https://registry.npmjs.org/fixture"); - await vi.runAllTimersAsync(); - await expect(result).resolves.toBeNull(); - expect(fetch).toHaveBeenCalledTimes(12); - }); - - for (const status of [401, 403, 429, 500, 503]) { - it(`does not retry HTTP ${status}`, async () => { - const fetch = vi.fn().mockResolvedValue(new Response(null, { status })); - vi.stubGlobal("fetch", fetch); - await expect(lookupPublished("https://registry.npmjs.org/fixture")).rejects.toThrow( - `HTTP ${status}`, - ); - expect(fetch).toHaveBeenCalledTimes(1); - }); - } - - it("does not retry malformed successful responses", async () => { - const fetch = vi.fn().mockResolvedValue(Response.json([])); - vi.stubGlobal("fetch", fetch); - await expect(lookupPublished("https://registry.npmjs.org/fixture")).rejects.toThrow( - "Expected object", - ); - expect(fetch).toHaveBeenCalledTimes(1); - }); - - it("does not retry network failures", async () => { - const fetch = vi.fn().mockRejectedValue(new Error("network unavailable")); - vi.stubGlobal("fetch", fetch); - await expect(lookupPublished("https://registry.npmjs.org/fixture")).rejects.toThrow( - "network unavailable", - ); - expect(fetch).toHaveBeenCalledTimes(1); - }); -}); - -describe("fixed release recovery", () => { - it("allows only the four recovery files to differ from the tag", () => { - expect(() => - checkInputs([ - ".github/workflows/release.yml", - "docs/releasing.md", - "apps/cli/scripts/recover-0.6.2.ts", - "apps/cli/test/recovery.test.ts", - ]), - ).not.toThrow(); - for (const file of [ - "package.json", - "pnpm-lock.yaml", - "src/lint/index.ts", - "scripts/build.ts", - "assets/logo.svg", - "test/other.test.ts", - "scripts/../package.json", - ]) { - expect(() => checkInputs([file])).toThrow("Package input changed"); - } - }); - - it("accepts only a confirmed 404 as absence", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status: 404 }))); - await expect(lookup("https://registry.npmjs.org/fixture")).resolves.toBeNull(); - }); - - for (const status of [401, 403, 429, 500, 503]) { - it(`stops on HTTP ${status} instead of republishing`, async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(null, { status }))); - await expect(lookup("https://registry.npmjs.org/fixture")).rejects.toThrow(`HTTP ${status}`); - }); - } - - it("preserves network failures", async () => { - vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network unavailable"))); - await expect(lookup("https://registry.npmjs.org/fixture")).rejects.toThrow( - "network unavailable", - ); - }); - - it("rejects malformed successful responses", async () => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(Response.json([]))); - await expect(lookup("https://registry.npmjs.org/fixture")).rejects.toThrow("Expected object"); - }); - - it("requires matching version, artifact integrity and provenance", () => { - const pkg = { - name: "@uinaf/attach-cli", - version: "0.6.2", - gitHead: "recovery-sha", - dist: { - integrity: "sha512-fixture", - attestations: { provenance: { predicateType: "https://slsa.dev/provenance/v1" } }, - }, - }; - expect(() => checkPackage(pkg, "sha512-fixture", "recovery-sha")).not.toThrow(); - expect(() => checkPackage(pkg, "sha512-fixture", "different-sha")).toThrow( - "npm gitHead differs", - ); - expect(() => checkPackage(pkg, "sha512-other")).toThrow("Published tarball differs"); - expect(() => checkPackage({ ...pkg, version: "0.6.3" })).toThrow(); - expect(() => checkPackage({ ...pkg, dist: { integrity: "sha512-fixture" } })).toThrow(); - }); - - it("requires the exact immutable published release", () => { - const release = { tag_name: "cli-v0.6.2", draft: false, prerelease: false, immutable: true }; - expect(() => checkRelease(release)).not.toThrow(); - expect(() => checkRelease({ ...release, tag_name: "cli-v0.6.1" })).toThrow(); - expect(() => checkRelease({ ...release, draft: true })).toThrow(); - expect(() => checkRelease({ ...release, immutable: false })).toThrow(); - }); -}); - -// Payload shape from npm's published @uinaf/design@1.14.3 bundle, adapted for recovery. -const statement = { - _type: "https://in-toto.io/Statement/v1", - subject: [ - { - name: "pkg:npm/%40uinaf/attach-cli@0.6.2", - digest: { - sha512: - "5474f59e4b3a709c5f527edcd6114770bf5951bfa0df62bc4636fc1d783af77d9568bb106825e758c4af5574bbebf794a52177bc5a387fe7bc51c4fe3a9ee47b", - }, - }, - ], - predicateType: "https://slsa.dev/provenance/v1", - predicate: { - buildDefinition: { - buildType: "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1", - externalParameters: { - workflow: { - ref: "refs/heads/main", - repository: "https://github.com/uinaf/attach", - path: ".github/workflows/release.yml", - }, - }, - internalParameters: { - github: { - event_name: "workflow_dispatch", - }, - }, - resolvedDependencies: [ - { - uri: "git+https://github.com/uinaf/attach@refs/heads/main", - digest: { - gitCommit: "b0b149290e443c0c11ea69d6e084b545e94ddc5d", - }, - }, - ], - }, - runDetails: { - builder: { - id: "https://github.com/actions/runner/github-hosted", - }, - metadata: { - invocationId: "https://github.com/uinaf/attach/actions/runs/32478126334/attempts/1", - }, - }, - }, -}; -const integrity = `sha512-${Buffer.from(statement.subject[0].digest.sha512, "hex").toString("base64")}`; -const buildSha = "b0b149290e443c0c11ea69d6e084b545e94ddc5d"; -function bundle(payload: unknown) { - return { - attestations: [ - { - predicateType: "https://slsa.dev/provenance/v1", - bundle: { - dsseEnvelope: { - payloadType: "application/vnd.in-toto+json", - payload: Buffer.from(JSON.stringify(payload)).toString("base64"), - }, - }, - }, - ], - }; -} -it("checks the actual npm provenance payload", () => { - expect(() => checkProvenance(bundle(statement), integrity, buildSha)).not.toThrow(); - expect(() => checkProvenance({ attestations: [] }, integrity, buildSha)).toThrow(); - expect(() => checkProvenance(bundle(statement), integrity, "different-sha")).toThrow( - "event commit", - ); -}); -it.each([ - [ - "subject digest", - (s: typeof statement) => { - s.subject[0].digest.sha512 = "0".repeat(128); - }, - ], - [ - "subject name", - (s: typeof statement) => { - s.subject[0].name = "pkg:npm/other@0.6.2"; - }, - ], - [ - "repository", - (s: typeof statement) => { - s.predicate.buildDefinition.externalParameters.workflow.repository = - "https://github.com/other/design"; - }, - ], - [ - "workflow", - (s: typeof statement) => { - s.predicate.buildDefinition.externalParameters.workflow.path = ".github/workflows/other.yml"; - }, - ], - [ - "ref", - (s: typeof statement) => { - s.predicate.buildDefinition.externalParameters.workflow.ref = "refs/tags/cli-v0.6.2"; - }, - ], - [ - "event", - (s: typeof statement) => { - s.predicate.buildDefinition.internalParameters.github.event_name = "push"; - }, - ], - [ - "runner", - (s: typeof statement) => { - s.predicate.runDetails.builder.id = "https://github.com/actions/runner/self-hosted"; - }, - ], -])("rejects a mismatched provenance %s", (_label, mutate) => { - const changed = structuredClone(statement); - mutate(changed); - expect(() => checkProvenance(bundle(changed), integrity, buildSha)).toThrow(); -}); diff --git a/apps/cli/test/release-commit.test.ts b/apps/cli/test/release-commit.test.ts new file mode 100644 index 0000000..78476d4 --- /dev/null +++ b/apps/cli/test/release-commit.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { URL } from "node:url"; +import { test, vi } from "vite-plus/test"; +import { prepare } from "../scripts/release-commit.ts"; + +const git = (cwd: string, ...args: string[]) => + execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +const commit = (cwd: string) => { + git(cwd, "add", "."); + git( + cwd, + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.invalid", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "fixture", + ); + return git(cwd, "rev-parse", "HEAD"); +}; +for (const mode of [ + "normal", + "advanced-before", + "advanced-after", + "source-edit", + "manifest-edit", + "http-error", + "wrong-parent", +] as const) { + test(`signed release writeback: ${mode}`, async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "attach-release-")); + const remote = path.join(root, "remote"); + const cwd = path.join(root, "checkout"); + fs.mkdirSync(path.join(remote, "apps/cli"), { recursive: true }); + git(remote, "init", "-b", "main"); + fs.writeFileSync(path.join(remote, "source.txt"), "verified source\n"); + fs.writeFileSync( + path.join(remote, "apps/cli/package.json"), + JSON.stringify({ name: "fixture", version: "1.0.0" }), + ); + const expected = commit(remote); + git(root, "clone", remote, cwd); + const content = JSON.stringify({ name: "fixture", version: "1.0.1" }); + fs.writeFileSync(path.join(cwd, "apps/cli/package.json"), content); + const context = { + cwd, + env: { GITHUB_SHA: expected, GITHUB_REPOSITORY: "uinaf/attach", GITHUB_TOKEN: "fixture" }, + nextRelease: { version: "1.0.1", gitHead: expected }, + }; + let created = ""; + if (mode === "advanced-before") { + fs.writeFileSync( + path.join(remote, "apps/cli/package.json"), + JSON.stringify({ name: "fixture", version: "1.0.0", newDependency: true }), + ); + commit(remote); + } + if (mode === "source-edit") fs.writeFileSync(path.join(cwd, "source.txt"), "unverified\n"); + if (mode === "manifest-edit") + fs.writeFileSync( + path.join(cwd, "apps/cli/package.json"), + JSON.stringify({ name: "other", version: "1.0.1" }), + ); + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (url, options) => { + assert.equal(url, "https://api.github.com/graphql"); + assert(typeof options?.body === "string"); + const { + variables: { input }, + } = JSON.parse(options.body); + assert.equal(input.expectedHeadOid, expected); + assert.deepEqual(input.branch, { + repositoryNameWithOwner: "uinaf/attach", + branchName: "main", + }); + assert.deepEqual(input.fileChanges.additions, [ + { path: "apps/cli/package.json", contents: Buffer.from(content).toString("base64") }, + ]); + if (mode === "http-error") return new Response("denied", { status: 403 }); + if (mode === "advanced-before") + return Response.json({ + data: null, + errors: [{ message: "expectedHeadOid does not match branch head" }], + }); + if (mode === "wrong-parent") { + fs.writeFileSync(path.join(remote, "source.txt"), "raced\n"); + commit(remote); + } + fs.writeFileSync(path.join(remote, "apps/cli/package.json"), content); + created = commit(remote); + if (mode === "advanced-after") { + fs.writeFileSync(path.join(remote, "source.txt"), "newer source\n"); + commit(remote); + } + return Response.json({ data: { createCommitOnBranch: { commit: { oid: created } } } }); + }); + try { + if (mode === "normal" || mode === "advanced-after") { + await prepare({}, context); + assert.equal(git(cwd, "rev-parse", "HEAD"), created); + assert.equal(context.nextRelease.gitHead, created); + assert.equal(git(cwd, "show", "-s", "--format=%P", "HEAD"), expected); + assert.equal(fs.readFileSync(path.join(cwd, "source.txt"), "utf8"), "verified source\n"); + assert.equal(fs.readFileSync(path.join(cwd, "apps/cli/package.json"), "utf8"), content); + if (mode === "advanced-after") assert.notEqual(git(remote, "rev-parse", "HEAD"), created); + } else { + await assert.rejects( + prepare({}, context), + /changed files|only the package version|expectedHeadOid|HTTP 403|unexpected parent/, + ); + assert.equal(context.nextRelease.gitHead, expected); + assert.equal(git(cwd, "rev-parse", "HEAD"), expected); + if (mode === "advanced-before") + assert.match( + fs.readFileSync(path.join(remote, "apps/cli/package.json"), "utf8"), + /newDependency/, + ); + if (mode === "source-edit" || mode === "manifest-edit") + assert.equal(fetchMock.mock.calls.length, 0); + } + assert.equal(git(remote, "tag", "--list"), ""); + } finally { + fetchMock.mockRestore(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test("release prepares npm before signed writeback, then publishes GitHub", () => { + const config = JSON.parse( + fs.readFileSync(new URL("../../../.releaserc.json", import.meta.url), "utf8"), + ); + assert.deepEqual(config.plugins[2], ["@semantic-release/npm", { pkgRoot: "apps/cli" }]); + assert.equal(config.plugins[3], "./apps/cli/scripts/release-commit.ts"); +}); diff --git a/docs/releasing.md b/docs/releasing.md index 8aa66dc..8033d07 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -24,10 +24,19 @@ Tags: `cli-v${version}`. Trusted publisher: workflow `release.yml`, environment `release`. During semantic-release prepare, the released `apps/cli/package.json` is -committed to `main` through GitHub's API as `uinaf-releaser[bot]`. GitHub signs +committed to `main` through GitHub's API as `uinaf-releaser[bot]`. The local +prepare plugin permits only the version change and atomically requires main +to match the verified event commit. If main advances before writeback, +publication stops. The checkout then uses the exact signed commit returned by +GitHub. GitHub signs that commit before semantic-release creates the tag, so the tag, immutable GitHub Release, and npm package refer to the checked-in released version. +If writeback succeeds but the subsequent fetch or validation fails, the signed +version commit can remain without a tag or npm publication. Inspect the exact +commit, tag, release, and registry state before recovery; do not blindly rerun +publication or move an existing tag. + Install: ```sh @@ -53,39 +62,10 @@ retries the idempotent formula update without publishing another version. Push to `main` → GitHub Environment `production` → wrangler (see [Deploy](deploy.md)). Do not deploy the production Worker from a laptop. -## Recover the interrupted CLI 0.6.2 publication - -npm rejected the self-hosted runner after semantic-release created signed -commit `7a0c78d311323ae0677389405c55f9d04c920928` and tag `cli-v0.6.2`. -The release job now uses GitHub-hosted Ubuntu because -[npm provenance requires a supported hosted runner](https://docs.npmjs.com/generating-provenance-statements/). - -Dispatch `release.yml` on `main` to run its fixed recovery path. It checks out -the exact event commit and validates the unchanged tag at its signed release -commit, ancestry, and package version. Only the recovery workflow, helper, test, -and this document may differ from the tag; package inputs must be unchanged. -The owning verification gate builds the package before publication. - -It publishes only a missing npm version through the existing OIDC identity, -then creates only a missing GitHub Release. npm integrity must match the -verified build; npm gitHead must match the event commit. The unchanged tag -remains at the original signed version commit. Lookup failures other than -a confirmed 404 stop recovery. Immediately after publication, missing registry -metadata or provenance is checked up to 12 times, five seconds apart, to allow -for observed npm visibility lag. Preflight still treats a confirmed 404 as absence. -The existing Homebrew updater then consumes -that exact npm version and writes the formula through the signed App path. - -The npm-served provenance bundle must identify the tarball's SHA-512 digest, -this repository's `release.yml`, a hosted runner, and the real dispatch event -commit on main. npm validates the signed bundle at ingestion; recovery checks -those payload claims against the package and event. -Release notes distinguish that build from the unchanged release tag. GitHub -environment variables are never overridden to pretend the workflow ran at -the old tag. - -This dispatch does not run semantic-release or deploy the Worker. It never -moves tags or creates another version. A repeat dispatch at the same commit -checks the existing package against the rebuilt tarball before continuing -with the idempotent Homebrew update. Remove this one-shot dispatch path after -publication and the formula have been verified. +## CLI 0.6.2 recovery completed + +CLI 0.6.2 is published with verified npm provenance and an immutable GitHub +Release. The Homebrew formula consumes the same npm tarball through a signed +commit. The temporary recovery dispatch has been removed. The release tag +remains at its original signed version commit; npm provenance and gitHead +identify the recovery build on main.