diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a044a5c..db5deaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ name: Release # then `npm trust github …` — see docs/releasing.md. CI owns every later release. on: + workflow_dispatch: push: branches: [main] @@ -14,7 +15,7 @@ permissions: {} jobs: verify: - if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + if: ${{ github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') }} runs-on: blacksmith-2vcpu-ubuntu-2404 timeout-minutes: 10 permissions: @@ -39,15 +40,19 @@ jobs: - run: pnpm exec vp run ready scan: - if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + if: ${{ github.event_name == 'push' && !contains(github.event.head_commit.message, '[skip ci]') }} permissions: contents: read uses: uinaf/.github/.github/workflows/scan.yml@main release: - if: ${{ !contains(github.event.head_commit.message, '[skip ci]') }} + 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')) }} needs: [verify, scan] - runs-on: blacksmith-2vcpu-ubuntu-2404 + # npm provenance requires a GitHub-hosted runner. + runs-on: ubuntu-24.04 timeout-minutes: 15 environment: release concurrency: @@ -61,6 +66,7 @@ jobs: with: persist-credentials: false fetch-depth: 0 + ref: ${{ github.sha }} - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: standalone: true @@ -71,7 +77,20 @@ 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 @@ -92,10 +111,12 @@ 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 @@ -110,11 +131,34 @@ jobs: 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: ${{ steps.semantic.outputs.new_release_git_tag }} + NEW_RELEASE_TAG: ${{ github.event_name == 'workflow_dispatch' && 'cli-v0.6.2' || steps.semantic.outputs.new_release_git_tag }} run: | set -euo pipefail tag="$NEW_RELEASE_TAG" diff --git a/apps/cli/scripts/recover-0.6.2.ts b/apps/cli/scripts/recover-0.6.2.ts new file mode 100644 index 0000000..099c141 --- /dev/null +++ b/apps/cli/scripts/recover-0.6.2.ts @@ -0,0 +1,201 @@ +// 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/test/recovery.test.ts b/apps/cli/test/recovery.test.ts new file mode 100644 index 0000000..7e3c74b --- /dev/null +++ b/apps/cli/test/recovery.test.ts @@ -0,0 +1,267 @@ +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/docs/releasing.md b/docs/releasing.md index 3fad269..8aa66dc 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -52,3 +52,40 @@ 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.