diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 21ef1d34..7587ec52 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,6 +1,14 @@ name: Release on: + workflow_dispatch: + inputs: + recover_tag: + description: Recover the verified 9.1.0 release without moving its tag + required: true + type: choice + options: + - v9.1.0 push: branches: - main @@ -17,6 +25,7 @@ jobs: outputs: publish: ${{ steps.resolve.outputs.publish }} tag: ${{ steps.resolve.outputs.tag }} + recovery: ${{ steps.resolve.outputs.recovery }} steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -24,26 +33,39 @@ jobs: - name: Resolve release tag id: resolve shell: bash + env: + RECOVERY_TAG: ${{ inputs.recover_tag }} run: | set -euo pipefail version="$(node -p "require('./package.json').version")" tag="v${version}" echo "tag=${tag}" >> "$GITHUB_OUTPUT" - if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + if [[ "${GITHUB_REF}" != "refs/heads/main" || "${RECOVERY_TAG}" != "v9.1.0" || "${RECOVERY_TAG}" != "${tag}" ]]; then + echo "::error::Release recovery requires main and the exact v9.1.0 tag." + exit 1 + fi + echo "publish=true" >> "$GITHUB_OUTPUT" + echo "recovery=true" >> "$GITHUB_OUTPUT" + elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then if [[ "${GITHUB_REF_NAME}" != "${tag}" ]]; then echo "::error::Release tag/version mismatch: tag=${GITHUB_REF_NAME}, package.json=${version}." exit 1 fi echo "publish=true" >> "$GITHUB_OUTPUT" + echo "recovery=false" >> "$GITHUB_OUTPUT" else echo "publish=false" >> "$GITHUB_OUTPUT" + echo "recovery=false" >> "$GITHUB_OUTPUT" fi verify-main-and-tag: needs: decide runs-on: ubuntu-latest timeout-minutes: 30 + env: + FLOW_RELEASE_RECOVERY_TAG: ${{ needs.decide.outputs.recovery == 'true' && needs.decide.outputs.tag || '' }} steps: - name: Check out repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -71,7 +93,16 @@ jobs: - name: Rebuild release candidate timeout-minutes: 2 - run: bun pm pack --destination . + shell: bash + run: | + set -euo pipefail + bun pm pack --destination . + if [[ -n "${FLOW_RELEASE_RECOVERY_TAG}" ]]; then + bun run scripts/restore-exact-release-artifact.ts \ + opencode-plugin-flow-9.1.0.tgz \ + evals/qualification/bundles/qb1-86bbc934222c2715d315521ff5041edc718eba6492ff679db50a6d494fc1bd6d/objects/sha256-0b7f7a66bf1910b5d0da3a235f891d532b4aa352a2dbd25e398325c18e051b24 \ + sha256:0b7f7a66bf1910b5d0da3a235f891d532b4aa352a2dbd25e398325c18e051b24 + fi - name: Report release evidence readiness without publishing timeout-minutes: 5 @@ -80,13 +111,20 @@ jobs: set -euo pipefail tarball="$(ls opencode-plugin-flow-*.tgz)" bun run release:metadata -- --artifact "$tarball" - bun run eval:canary -- verify --artifact "$tarball" --mode dry-run + if [[ -n "${FLOW_RELEASE_RECOVERY_TAG}" ]]; then + bun run release:metadata -- --tag "$FLOW_RELEASE_RECOVERY_TAG" --artifact "$tarball" --canary evals/canary/9.1.0.json + bun run eval:canary -- verify --artifact "$tarball" --mode strict + else + bun run eval:canary -- verify --artifact "$tarball" --mode dry-run + fi release: needs: [decide, verify-main-and-tag] if: needs.decide.outputs.publish == 'true' runs-on: ubuntu-latest timeout-minutes: 30 + env: + FLOW_RELEASE_RECOVERY_TAG: ${{ needs.decide.outputs.recovery == 'true' && needs.decide.outputs.tag || '' }} concurrency: group: release-publication cancel-in-progress: false @@ -140,6 +178,12 @@ jobs: run: | set -euo pipefail bun pm pack --destination . + if [[ -n "${FLOW_RELEASE_RECOVERY_TAG}" ]]; then + bun run scripts/restore-exact-release-artifact.ts \ + opencode-plugin-flow-9.1.0.tgz \ + evals/qualification/bundles/qb1-86bbc934222c2715d315521ff5041edc718eba6492ff679db50a6d494fc1bd6d/objects/sha256-0b7f7a66bf1910b5d0da3a235f891d532b4aa352a2dbd25e398325c18e051b24 \ + sha256:0b7f7a66bf1910b5d0da3a235f891d532b4aa352a2dbd25e398325c18e051b24 + fi - name: Restore release record from this workflow run id: restore @@ -177,10 +221,14 @@ jobs: elif [ -f "evals/qualification/patches/${version}.json" ]; then evidence=(--patch "evals/qualification/patches/${version}.json") fi + commit="${GITHUB_SHA}" + if [[ -n "${FLOW_RELEASE_RECOVERY_TAG}" ]]; then + commit="$(git rev-parse "${FLOW_RELEASE_RECOVERY_TAG}^{commit}")" + fi bun run scripts/release.ts init .release-state \ --artifact "$tarball" \ "${evidence[@]}" \ - --commit "${GITHUB_SHA}" + --commit "$commit" - name: Persist release record before publication if: steps.restore.outputs.restored != 'true' diff --git a/scripts/release-publish.ts b/scripts/release-publish.ts index e0054e86..bd113765 100644 --- a/scripts/release-publish.ts +++ b/scripts/release-publish.ts @@ -12,6 +12,9 @@ const MUTATION_ATTEMPTS = 3; const RETRY_DELAY_MS = 5_000; const MAX_COMMAND_OUTPUT_BYTES = 1_000_000; const MAX_RELEASE_PAGES = 10; +const RECOVERY_TAG = "v9.1.0"; +const RECOVERY_TAG_OBJECT = "c629deb583185b977908f2203fab0caee69484a9"; +const RECOVERY_TAG_COMMIT = "727308d2ccd5761f03024341328cff06887ebae1"; export type CommandResult = { readonly exitCode: number; @@ -47,6 +50,22 @@ export type ReleaseRefEvidence = { readonly mainCommitSha: string; }; +export type RecoveryRefEvidence = { + readonly expectedTag: string; + readonly requestedTag: string; + readonly eventName: string; + readonly eventRefType: string; + readonly eventRefName: string; + readonly eventSha: string; + readonly headSha: string; + readonly localTagObjectSha: string; + readonly localTagCommitSha: string; + readonly remoteTagObjectSha: string; + readonly remoteTagCommitSha: string; + readonly mainCommitSha: string; + readonly tagAncestorOfMain: boolean; +}; + type NpmPublicationInput = { readonly packageName: string; readonly packageVersion: string; @@ -208,6 +227,38 @@ export function releaseRefIssue(evidence: ReleaseRefEvidence): string | null { return null; } +export function releaseRecoveryRefIssue( + evidence: RecoveryRefEvidence, + requireCurrentMain: boolean, +): string | null { + if ( + evidence.eventName !== "workflow_dispatch" || + evidence.eventRefType !== "branch" || + evidence.eventRefName !== "main" + ) + return "Release recovery requires a dispatch from the main branch."; + if ( + evidence.expectedTag !== RECOVERY_TAG || + evidence.requestedTag !== evidence.expectedTag || + evidence.eventSha !== evidence.headSha + ) + return "Release recovery input or checkout differs from the dispatch."; + if ( + evidence.localTagObjectSha !== RECOVERY_TAG_OBJECT || + evidence.localTagCommitSha !== RECOVERY_TAG_COMMIT + ) + return "Release recovery tag no longer identifies the pinned 9.1.0 release."; + if (evidence.localTagObjectSha !== evidence.remoteTagObjectSha) + return "The remote tag object no longer matches the checked-out release tag."; + if (evidence.localTagCommitSha !== evidence.remoteTagCommitSha) + return "The remote tag commit no longer matches the checked-out release tag."; + if (evidence.tagAncestorOfMain !== true) + return "Release recovery tag is not an ancestor of current main."; + if (requireCurrentMain && evidence.mainCommitSha !== evidence.headSha) + return "Release recovery checkout is not the current origin/main commit."; + return null; +} + async function revParse( runtime: PublicationRuntime, revision: string, @@ -286,6 +337,73 @@ export async function verifyReleaseRef( } } +export async function verifyReleaseRecoveryRef( + tag: string, + runtime: PublicationRuntime, + requireCurrentMain = true, +): Promise { + const packageJson = JSON.parse(await readFile("package.json", "utf8")) as { + version?: unknown; + }; + if (typeof packageJson.version !== "string") + throw new Error("package.json does not contain a release version."); + if ( + tag !== `v${packageJson.version}` || + tag !== process.env.FLOW_RELEASE_RECOVERY_TAG + ) + throw new Error("Release recovery tag differs from package or dispatch."); + const runIdentity = `${process.env.GITHUB_RUN_ID ?? "local"}-${process.env.GITHUB_RUN_ATTEMPT ?? "1"}`; + const mainRef = `refs/flow-release/${runIdentity}/recovery-main`; + const tagRef = `refs/flow-release/${runIdentity}/recovery-tag`; + const fetchArgs = [ + "fetch", + "--force", + "--no-tags", + "origin", + `+refs/heads/main:${mainRef}`, + `+refs/tags/${tag}:${tagRef}`, + ]; + await checkedCommand(runtime, "git", fetchArgs, REMOTE_COMMAND_TIMEOUT_MS); + try { + const tagAncestorOfMain = await runtime.run( + "git", + ["merge-base", "--is-ancestor", `${tagRef}^{commit}`, mainRef], + LOCAL_COMMAND_TIMEOUT_MS, + ); + if ( + tagAncestorOfMain.timedOut || + (tagAncestorOfMain.exitCode !== 0 && tagAncestorOfMain.exitCode !== 1) + ) + throw new Error("Release recovery ancestry check failed."); + const evidence: RecoveryRefEvidence = { + expectedTag: `v${packageJson.version}`, + requestedTag: process.env.FLOW_RELEASE_RECOVERY_TAG ?? "", + eventName: process.env.GITHUB_EVENT_NAME ?? "", + eventRefType: process.env.GITHUB_REF_TYPE ?? "", + eventRefName: process.env.GITHUB_REF_NAME ?? "", + eventSha: process.env.GITHUB_SHA ?? "", + headSha: await revParse(runtime, "HEAD^{commit}"), + localTagObjectSha: await revParse(runtime, `refs/tags/${tag}`), + localTagCommitSha: await revParse(runtime, `refs/tags/${tag}^{commit}`), + remoteTagObjectSha: await revParse(runtime, tagRef), + remoteTagCommitSha: await revParse(runtime, `${tagRef}^{commit}`), + mainCommitSha: await revParse(runtime, `${mainRef}^{commit}`), + tagAncestorOfMain: tagAncestorOfMain.exitCode === 0, + }; + const issue = releaseRecoveryRefIssue(evidence, requireCurrentMain); + if (issue) throw new Error(issue); + return evidence; + } finally { + for (const ref of [mainRef, tagRef]) { + await runtime.run( + "git", + ["update-ref", "-d", ref], + LOCAL_COMMAND_TIMEOUT_MS, + ); + } + } +} + async function fetchBounded( runtime: PublicationRuntime, input: string, diff --git a/scripts/release.ts b/scripts/release.ts index d3eab29b..e04bae3d 100644 --- a/scripts/release.ts +++ b/scripts/release.ts @@ -20,6 +20,7 @@ import { convergeNpmPublication, defaultRuntime, type PublicationRuntime, + verifyReleaseRecoveryRef, verifyReleaseRef, } from "./release-publish.js"; @@ -47,6 +48,7 @@ export const ReleaseRecordSchema = z bundles: z.string().min(1), bundleSha256: Hash, creationOwner: z.string().min(1), + publicationMode: z.literal("recovery").optional(), }) .strict(); export type ReleaseRecord = z.infer; @@ -170,6 +172,12 @@ export async function resumeRelease( }, ): Promise { const record = await loadRelease(directory); + const recovery = record.publicationMode === "recovery"; + if ( + recovery !== Boolean(process.env.FLOW_RELEASE_RECOVERY_TAG) || + (recovery && process.env.FLOW_RELEASE_RECOVERY_TAG !== record.tag) + ) + throw new Error("Release recovery mode differs from its durable record."); await verify(record); const github = { repository: record.repository, @@ -183,8 +191,11 @@ export async function resumeRelease( ], }; const proof = async (currentMain: boolean) => { - const evidence = await verifyReleaseRef(record.tag, runtime, currentMain); - if (evidence.headSha !== record.commit) + const commit = recovery + ? (await verifyReleaseRecoveryRef(record.tag, runtime, currentMain)) + .localTagCommitSha + : (await verifyReleaseRef(record.tag, runtime, currentMain)).headSha; + if (commit !== record.commit) throw new Error("Release record commit differs from checkout."); }; const prepared = await convergeGithubRelease( @@ -271,6 +282,9 @@ async function initialize(directory: string, options: Map) { : "evals/qualification/bundles")); const metadata = JSON.parse(await readFile("package.json", "utf8")); const tag = `v${metadata.version}`; + const recovery = process.env.FLOW_RELEASE_RECOVERY_TAG; + if (recovery && recovery !== tag) + throw new Error("Release recovery tag differs from package version."); const artifact = await inspectArtifact({ repositoryRoot: process.cwd(), tarballPath: artifactPath, @@ -296,7 +310,8 @@ async function initialize(directory: string, options: Map) { record.canary !== canary || record.patch !== patch || record.feature !== feature || - record.bundles !== bundles + record.bundles !== bundles || + record.publicationMode !== (recovery ? "recovery" : undefined) ) throw new Error( "Existing release record conflicts with requested inputs.", @@ -355,6 +370,7 @@ async function initialize(directory: string, options: Map) { bundles, bundleSha256: evidence.bundleSha256, creationOwner: owner(), + ...(recovery ? { publicationMode: "recovery" as const } : {}), }); await writeExclusive(join(directory, "release.json"), record); } diff --git a/scripts/restore-exact-release-artifact.ts b/scripts/restore-exact-release-artifact.ts new file mode 100644 index 00000000..ab1c8a2a --- /dev/null +++ b/scripts/restore-exact-release-artifact.ts @@ -0,0 +1,30 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { unpackedManifestSha256 } from "../evals/provenance.js"; + +const [rebuiltPath, sealedPath, expectedSha256] = process.argv.slice(2); +if ( + !rebuiltPath || + !sealedPath || + !/^sha256:[a-f0-9]{64}$/.test(expectedSha256 ?? "") +) + throw new Error( + "Expected rebuilt tarball, sealed bundle object, and SHA-256.", + ); + +const sealedBytes = await readFile(sealedPath); +const actualSha256 = `sha256:${createHash("sha256").update(sealedBytes).digest("hex")}`; +if (actualSha256 !== expectedSha256) + throw new Error("Sealed release artifact has the wrong digest."); + +const [rebuiltManifest, sealedManifest] = await Promise.all([ + unpackedManifestSha256(rebuiltPath), + unpackedManifestSha256(sealedPath), +]); +if (rebuiltManifest !== sealedManifest) + throw new Error( + "Rebuilt package contents differ from the qualified artifact.", + ); + +await writeFile(rebuiltPath, sealedBytes); +console.log(`Restored exact release artifact ${actualSha256}.`); diff --git a/tests/documentation-contract.test.ts b/tests/documentation-contract.test.ts index 85c09116..0c38758d 100644 --- a/tests/documentation-contract.test.ts +++ b/tests/documentation-contract.test.ts @@ -644,7 +644,9 @@ describe("Flow documentation contract", () => { expect(release).toMatch(/^ {2}push:\n {4}branches:/m); expect(release).toContain("tags:"); expect(release).toMatch(/tag="v\$\{version\}"/); - expect(release).toMatch(/--commit "\$\{GITHUB_SHA\}"/); + expect(release).toMatch(/commit="\$\{GITHUB_SHA\}"/); + expect(release).toContain('commit="$(git rev-parse "'); + expect(release).toContain('--commit "$commit"'); expect(release).toContain("Verify selected release evidence"); expect(release).toContain("bun run eval:canary -- verify"); expect(release).toContain("--mode dry-run"); diff --git a/tests/release-publish.test.ts b/tests/release-publish.test.ts index c87d0508..56d49af2 100644 --- a/tests/release-publish.test.ts +++ b/tests/release-publish.test.ts @@ -9,12 +9,16 @@ import { convergeGithubRelease, convergeNpmPublication, type PublicationRuntime, + type RecoveryRefEvidence, type ReleaseRefEvidence, + releaseRecoveryRefIssue, releaseRefIssue, } from "../scripts/release-publish.js"; const COMMIT = "0123456789abcdef0123456789abcdef01234567"; const TAG_OBJECT = "89abcdef0123456789abcdef0123456789abcdef"; +const RECOVERY_COMMIT = "727308d2ccd5761f03024341328cff06887ebae1"; +const RECOVERY_TAG_OBJECT = "c629deb583185b977908f2203fab0caee69484a9"; function refEvidence( overrides: Partial = {}, @@ -33,6 +37,27 @@ function refEvidence( }; } +function recoveryEvidence( + overrides: Partial = {}, +): RecoveryRefEvidence { + return { + expectedTag: "v9.1.0", + requestedTag: "v9.1.0", + eventName: "workflow_dispatch", + eventRefType: "branch", + eventRefName: "main", + eventSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + headSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + localTagObjectSha: RECOVERY_TAG_OBJECT, + localTagCommitSha: RECOVERY_COMMIT, + remoteTagObjectSha: RECOVERY_TAG_OBJECT, + remoteTagCommitSha: RECOVERY_COMMIT, + mainCommitSha: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + tagAncestorOfMain: true, + ...overrides, + }; +} + function result(exitCode: number, stdout = "", stderr = ""): CommandResult { return { exitCode, stdout, stderr, timedOut: false }; } @@ -57,6 +82,56 @@ function jsonResponse(status: number, body: unknown): Response { } describe("release ref proof", () => { + test("accepts only a current-main dispatch for an unchanged ancestor tag", () => { + expect(releaseRecoveryRefIssue(recoveryEvidence(), true)).toBeNull(); + expect( + releaseRecoveryRefIssue( + recoveryEvidence({ + mainCommitSha: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + }), + false, + ), + ).toBeNull(); + expect( + releaseRecoveryRefIssue( + recoveryEvidence({ tagAncestorOfMain: false }), + false, + ), + ).toContain("ancestor"); + }); + + test("recovery refuses a moved tag, unrelated main, or unreviewed dispatch", () => { + expect( + releaseRecoveryRefIssue( + recoveryEvidence({ remoteTagObjectSha: COMMIT }), + true, + ), + ).toContain("remote tag"); + expect( + releaseRecoveryRefIssue( + recoveryEvidence({ localTagCommitSha: COMMIT }), + true, + ), + ).toContain("pinned 9.1.0"); + expect( + releaseRecoveryRefIssue( + recoveryEvidence({ tagAncestorOfMain: false }), + true, + ), + ).toContain("ancestor"); + expect( + releaseRecoveryRefIssue( + recoveryEvidence({ eventRefName: "feature" }), + true, + ), + ).toContain("main branch"); + expect( + releaseRecoveryRefIssue( + recoveryEvidence({ mainCommitSha: COMMIT }), + true, + ), + ).toContain("current origin/main"); + }); test("obsolete publication commands fail with migration guidance", () => { const legacy = spawnSync( "bun", diff --git a/tests/release-record.test.ts b/tests/release-record.test.ts index 8f1be343..98af324a 100644 --- a/tests/release-record.test.ts +++ b/tests/release-record.test.ts @@ -23,6 +23,7 @@ afterEach(async () => { "GITHUB_REF_TYPE", "GITHUB_REF_NAME", "GITHUB_SHA", + "FLOW_RELEASE_RECOVERY_TAG", ]) { if (previous[key] === undefined) delete process.env[key]; else process.env[key] = previous[key]; @@ -34,6 +35,7 @@ afterEach(async () => { ); }); async function fixture() { + delete process.env.FLOW_RELEASE_RECOVERY_TAG; delete process.env.GITHUB_RUN_ID; delete process.env.GITHUB_RUN_ATTEMPT; process.env.GITHUB_REF_TYPE = "tag"; @@ -66,6 +68,23 @@ async function fixture() { await writeFile(join(directory, "release.json"), JSON.stringify(record)); return { directory, record, bytes }; } + +test("recovery records require the matching dispatch mode before any mutation", async () => { + const input = await fixture(); + const remote = transport(input); + await writeFile( + join(input.directory, "release.json"), + JSON.stringify({ ...input.record, publicationMode: "recovery" }), + ); + await expect( + resumeRelease(input.directory, remote.runtime, async () => {}), + ).rejects.toThrow("recovery mode"); + process.env.FLOW_RELEASE_RECOVERY_TAG = "v0.0.0"; + await expect( + resumeRelease(input.directory, remote.runtime, async () => {}), + ).rejects.toThrow("recovery mode"); + expect(remote.counts()).toEqual({ creates: 0, publishes: 0 }); +}); function transport(input: Awaited>) { const releases: Array<{ id: number;