diff --git a/.github/workflows/fork-release.yml b/.github/workflows/fork-release.yml index cc8f6032c646..43597e256380 100644 --- a/.github/workflows/fork-release.yml +++ b/.github/workflows/fork-release.yml @@ -55,6 +55,11 @@ on: required: false default: "" type: string + native_receipts_json: + description: "JSON array of genuine native acceptance receipts for receipts_source_run_id (max 32 KiB)." + required: false + default: "" + type: string receipt_run_id: description: "Run id holding fork-release-native-receipts to promote with. Defaults to candidate_run_id." required: false @@ -177,6 +182,7 @@ jobs: bundle: name: Build JS bundle + if: ${{ !inputs.upload_receipts }} needs: [preflight] runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -244,6 +250,7 @@ jobs: cli_linux_x64: name: Linux x64 runtime archive + if: ${{ !inputs.upload_receipts }} needs: [preflight, bundle] runs-on: ubuntu-24.04 timeout-minutes: 30 @@ -391,6 +398,7 @@ jobs: desktop_win_x64: name: Desktop Windows x64 + if: ${{ !inputs.upload_receipts }} needs: [preflight, bundle, cli_linux_x64] uses: ./.github/workflows/release-desktop.yml secrets: inherit @@ -417,6 +425,7 @@ jobs: desktop_mac_x64: name: Desktop macOS x64 + if: ${{ !inputs.upload_receipts }} needs: [preflight, bundle] uses: ./.github/workflows/release-desktop.yml secrets: inherit @@ -441,7 +450,7 @@ jobs: desktop_mac_arm64: name: Desktop macOS arm64 - if: ${{ inputs.include_macos_arm64 }} + if: ${{ inputs.include_macos_arm64 && !inputs.upload_receipts }} needs: [preflight, bundle] uses: ./.github/workflows/release-desktop.yml secrets: inherit @@ -466,7 +475,7 @@ jobs: qualify: name: Qualify candidate needs: [preflight, desktop_win_x64, desktop_mac_x64, desktop_mac_arm64, cli_linux_x64] - if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.cli_linux_x64.result == 'success' && (inputs.include_macos_arm64 == false || needs.desktop_mac_arm64.result == 'success') }} + if: ${{ !cancelled() && !inputs.upload_receipts && needs.preflight.result == 'success' && needs.desktop_win_x64.result == 'success' && needs.desktop_mac_x64.result == 'success' && needs.cli_linux_x64.result == 'success' && (inputs.include_macos_arm64 == false || needs.desktop_mac_arm64.result == 'success') }} runs-on: ubuntu-24.04 timeout-minutes: 20 steps: @@ -673,17 +682,14 @@ jobs: if-no-files-found: error retention-days: 14 - # A real, repeatable receipt upload path. Native machines produce a receipt - # JSON and a caller dispatches this job with `upload_receipts: true`, naming - # the candidate run whose identity the receipts are bound to. The job verifies - # each receipt binds to that candidate's manifest digest and source SHA before - # uploading `fork-release-native-receipts` on *this* run. Promotion then reads - # this run's receipt artifact (see `receipt_run_id`), never a completed build - # run that has no mechanism to receive one. + # A caller supplies genuine native receipts as bounded JSON, and names the + # already-qualified candidate run they accept. This job verifies the exact + # frozen candidate identity and every receipt before uploading the receipt + # artifact on this run. It does not rebuild or modify the candidate. receipts: name: Import native acceptance receipts - needs: [preflight, qualify] - if: ${{ !cancelled() && needs.qualify.result == 'success' && inputs.upload_receipts && inputs.receipts_source_run_id != '' }} + needs: [preflight] + if: ${{ !cancelled() && needs.preflight.result == 'success' && inputs.upload_receipts }} runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: @@ -724,8 +730,70 @@ jobs: --name fork-release-candidate \ --dir candidate - # The operator-supplied receipts file is committed/attached out of band. - # This job never invents one; it fails closed when the file is absent. + - name: Bind the downloaded candidate to its recorded identity + shell: bash + env: + CANDIDATE_RUN_ID: ${{ inputs.receipts_source_run_id }} + RELEASE_VERSION: ${{ needs.preflight.outputs.version }} + RELEASE_SHA: ${{ needs.preflight.outputs.sha }} + run: | + set -euo pipefail + node -e ' + const fs = require("node:fs"); + const crypto = require("node:crypto"); + const identity = JSON.parse(fs.readFileSync("candidate/candidate-identity.json", "utf8")); + const manifestBytes = fs.readFileSync("candidate/fork-release-manifest.json"); + const manifest = JSON.parse(manifestBytes.toString("utf8")); + const digest = crypto.createHash("sha256").update(manifestBytes).digest("hex"); + const expected = { + runId: process.env.CANDIDATE_RUN_ID, + repository: "nullStack65/t3code", + version: process.env.RELEASE_VERSION, + sourceSha: process.env.RELEASE_SHA, + }; + for (const [key, value] of Object.entries(expected)) { + const manifestKey = key === "runId" ? "workflowRunId" : key; + if (identity[key] !== value || manifest[manifestKey] !== value) { + console.error(`::error::candidate identity/manifest ${key} does not match selected candidate (${value})`); + process.exit(1); + } + } + if (identity.runAttempt !== manifest.workflowRunAttempt) { + console.error("::error::candidate identity attempt does not match frozen manifest"); + process.exit(1); + } + if (digest !== identity.manifestSha256) { + console.error(`::error::downloaded manifest digest ${digest} does not match frozen identity ${identity.manifestSha256}`); + process.exit(1); + } + console.log("Candidate bound to frozen identity:", identity.manifestSha256); + ' + + - name: Write the supplied native receipts + shell: bash + env: + RECEIPTS_JSON: ${{ inputs.native_receipts_json }} + run: | + set -euo pipefail + node -e ' + const fs = require("node:fs"); + const raw = process.env.RECEIPTS_JSON ?? ""; + if (Buffer.byteLength(raw, "utf8") === 0) { + console.error("::error::native_receipts_json is required when upload_receipts is true"); + process.exit(1); + } + if (Buffer.byteLength(raw, "utf8") > 32 * 1024) { + console.error("::error::native_receipts_json exceeds the 32 KiB limit"); + process.exit(1); + } + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) { + console.error("::error::native_receipts_json must be a JSON array"); + process.exit(1); + } + fs.writeFileSync("fork-native-receipts.json", raw + "\n", { flag: "wx" }); + ' + - name: Validate and stage native receipts shell: bash env: @@ -733,10 +801,6 @@ jobs: RELEASE_SHA: ${{ needs.preflight.outputs.sha }} run: | set -euo pipefail - test -f fork-native-receipts.json || { - echo "::error::fork-native-receipts.json was not provided; native acceptance must be recorded before import" - exit 1 - } mkdir -p receipts node scripts/verify-fork-candidate.ts \ --candidate-dir candidate \ diff --git a/docs/operations/fork-release.md b/docs/operations/fork-release.md index 5ee85f8c5b33..1350a89aa0df 100644 --- a/docs/operations/fork-release.md +++ b/docs/operations/fork-release.md @@ -283,12 +283,19 @@ Rules: 6. Native acceptance on real Windows/WSL and Intel macOS hardware. Each target binds to its own installer/runtime asset and its digest; a receipt for the wrong artifact, or a conflicting FAIL beside a PASS, is rejected. Record the - accepted bytes as `fork-native-receipts.json` and import them by dispatching - the **Fork release** workflow with `upload_receipts: true` and - `receipts_source_run_id` = the candidate run id. That job downloads the - candidate identity, validates the receipts against it, and uploads the - `fork-release-native-receipts` artifact on the _import_ run. A changed or - rebuilt asset invalidates its previous receipt. + actual results in a JSON array of native receipts. Dispatch the **Fork + release** workflow using the same `sha` and `version` as the qualified + candidate, with `upload_receipts: true`, `receipts_source_run_id` set to the + candidate run id, and `native_receipts_json` set to the JSON array (maximum + 32 KiB). Each entry carries `schemaVersion`, `owner`, `target`, `sourceSha`, + `version`, `assetName`, `assetSha256`, and `result` (`pass` or `fail`); do not + report a pass until that target's real native acceptance has completed. The + import job downloads that run's candidate and checks its run ID, source, + version, repository, attempt, and manifest SHA-256 before checking every + receipt against the frozen asset digest and target. It does not rebuild or + modify the candidate. On success, it uploads `fork-release-native-receipts` + on the _import_ run. A changed or rebuilt asset invalidates its previous + receipt. 7. Re-run with `publish: true`, `candidate_run_id` set to the qualifying run, and `receipt_run_id` set to the import run (defaults to `candidate_run_id`). Promotion downloads the frozen candidate, binds it to its recorded diff --git a/scripts/lib/fork-release-workflow.test.ts b/scripts/lib/fork-release-workflow.test.ts index 84f99c30fb17..0352fa507090 100644 --- a/scripts/lib/fork-release-workflow.test.ts +++ b/scripts/lib/fork-release-workflow.test.ts @@ -1,5 +1,9 @@ -// @effect-diagnostics nodeBuiltinImport:off - Reads the workflow files as text to assert the job graph. +// @effect-diagnostics nodeBuiltinImport:off - Reads and executes inline workflow checks in scratch fixtures. +import * as NodeCrypto from "node:crypto"; +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import { assert, it } from "@effect/vitest"; @@ -36,6 +40,77 @@ const inlineList = (block: string, key: string): string[] => { const scalar = (block: string, key: string): string | undefined => new RegExp(`^\\s*${key}:\\s*(.+)$`, "m").exec(block)?.[1]?.trim(); +/** Reads a step's actual block-scalar shell script from its workflow job. */ +function stepScript(block: string, name: string): string { + const lines = block.split(/\r?\n/); + const start = lines.findIndex((line) => line === ` - name: ${name}`); + assert.notEqual(start, -1, `step ${name} not found`); + const run = lines.findIndex((line, index) => index > start && line === " run: |"); + assert.notEqual(run, -1, `step ${name} has no run block`); + const body: string[] = []; + for (let index = run + 1; index < lines.length; index += 1) { + const line = lines[index]!; + if (line !== "" && !line.startsWith(" ")) break; + body.push(line === "" ? "" : line.slice(10)); + } + return body.join("\n"); +} + +/** Extracts the inline Node program executed by a workflow step. */ +function inlineNodeProgram(script: string): string { + const match = /^node -e '\n([\s\S]*?)\n'$/m.exec(script); + assert.isNotNull(match, "workflow step must execute its inline Node program"); + return match[1]!; +} + +function runNodeProgram( + program: string, + cwd: string, + env: Readonly>, +): NodeChildProcess.SpawnSyncReturns { + return NodeChildProcess.spawnSync(process.execPath, ["-e", program], { + cwd, + env: { ...process.env, ...env }, + encoding: "utf8", + timeout: 10_000, + }); +} + +function scratchCandidate( + overrides: { + readonly identity?: Readonly> | undefined; + readonly manifest?: Readonly> | undefined; + } = {}, +): string { + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "fork-release-receipt-")); + const candidate = NodePath.join(root, "candidate"); + NodeFS.mkdirSync(candidate); + const manifest = { + repository: "nullStack65/t3code", + version: "0.0.43", + sourceSha: "a".repeat(40), + workflowRunId: "123456789", + workflowRunAttempt: "2", + ...overrides.manifest, + }; + const manifestBytes = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`); + NodeFS.writeFileSync(NodePath.join(candidate, "fork-release-manifest.json"), manifestBytes); + const identity = { + runId: "123456789", + runAttempt: "2", + repository: "nullStack65/t3code", + version: "0.0.43", + sourceSha: "a".repeat(40), + manifestSha256: NodeCrypto.createHash("sha256").update(manifestBytes).digest("hex"), + ...overrides.identity, + }; + NodeFS.writeFileSync( + NodePath.join(candidate, "candidate-identity.json"), + `${JSON.stringify(identity, null, 2)}\n`, + ); + return root; +} + it.effect("the qualify job depends on the optional arm64 job and handles skipped", () => Effect.gen(function* () { const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); @@ -136,11 +211,133 @@ it.effect("promotion consumes the frozen candidate identity and requires reviewe const receipts = jobBlock(text, "receipts"); assert.include(receipts, "upload_receipts"); assert.include(receipts, "receipts_source_run_id"); + assert.include(text, "native_receipts_json"); assert.include(receipts, "fork-release-native-receipts"); assert.include(receipts, "upload-artifact"); }), ); +it.effect( + "receipt import binds bounded supplied JSON to the exact frozen candidate without rebuilding", + () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const receipts = jobBlock(text, "receipts"); + assert.deepEqual(inlineList(receipts, "needs"), ["preflight"]); + assert.include(receipts, "inputs.upload_receipts"); + assert.notInclude(receipts, "needs.qualify"); + assert.include(receipts, "RECEIPTS_JSON: ${{ inputs.native_receipts_json }}"); + assert.include(receipts, 'Buffer.byteLength(raw, "utf8") > 32 * 1024'); + assert.include(receipts, 'flag: "wx"'); + assert.include(receipts, "identity[key] !== value"); + assert.include(receipts, "digest !== identity.manifestSha256"); + assert.include(receipts, "workflowRunAttempt"); + assert.include(receipts, "--native-receipts fork-native-receipts.json"); + assert.include(receipts, "--require-native-receipts"); + assert.isBelow( + receipts.indexOf("--require-native-receipts"), + receipts.indexOf("name: Upload native acceptance receipts"), + ); + assert.notInclude(receipts, "build-cli-archive.ts"); + assert.notInclude(receipts, "build-desktop-artifact.ts"); + for (const job of [ + "bundle", + "cli_linux_x64", + "desktop_win_x64", + "desktop_mac_x64", + "desktop_mac_arm64", + "qualify", + ]) { + assert.include( + jobBlock(text, job), + "inputs.upload_receipts", + `${job} must stay out of receipt imports`, + ); + } + }), +); + +it.effect("the workflow candidate-binding program rejects identity and digest tampering", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const receipts = jobBlock(text, "receipts"); + const program = inlineNodeProgram( + stepScript(receipts, "Bind the downloaded candidate to its recorded identity"), + ); + const env = { + CANDIDATE_RUN_ID: "123456789", + RELEASE_VERSION: "0.0.43", + RELEASE_SHA: "a".repeat(40), + }; + + const validRoot = scratchCandidate(); + try { + const result = runNodeProgram(program, validRoot, env); + assert.equal(result.status, 0, result.stderr); + } finally { + NodeFS.rmSync(validRoot, { recursive: true, force: true }); + } + + const tamperingCases = [ + { name: "run ID", identity: { runId: "987654321" } }, + { name: "repository", manifest: { repository: "other/repository" } }, + { name: "source SHA", identity: { sourceSha: "b".repeat(40) } }, + { name: "version", manifest: { version: "0.0.44" } }, + { name: "attempt", identity: { runAttempt: "3" } }, + { name: "manifest digest", identity: { manifestSha256: "0".repeat(64) } }, + ]; + for (const testCase of tamperingCases) { + const root = scratchCandidate({ + identity: testCase.identity, + manifest: testCase.manifest, + }); + try { + const result = runNodeProgram(program, root, env); + assert.notEqual(result.status, 0, `tampered ${testCase.name} was accepted`); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + } + }), +); + +it.effect("the workflow receipt-input program rejects missing, oversized, and malformed JSON", () => + Effect.gen(function* () { + const text = yield* Effect.promise(() => readWorkflow("fork-release.yml")); + const receipts = jobBlock(text, "receipts"); + const program = inlineNodeProgram(stepScript(receipts, "Write the supplied native receipts")); + const invalidInputs = [ + { name: "missing payload", value: "" }, + { name: "UTF-8 oversized payload", value: "é".repeat(16_385) }, + { name: "malformed JSON", value: "[" }, + { name: "non-array JSON", value: "{}" }, + ]; + for (const input of invalidInputs) { + const root = NodeFS.mkdtempSync( + NodePath.join(NodeOS.tmpdir(), "fork-release-receipt-input-"), + ); + try { + const result = runNodeProgram(program, root, { RECEIPTS_JSON: input.value }); + assert.notEqual(result.status, 0, `${input.name} was accepted`); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + } + + const root = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "fork-release-receipt-input-")); + try { + const result = runNodeProgram(program, root, { RECEIPTS_JSON: "[]" }); + assert.equal(result.status, 0, result.stderr); + assert.equal( + NodeFS.readFileSync(NodePath.join(root, "fork-native-receipts.json"), "utf8"), + "[]\n", + ); + } finally { + NodeFS.rmSync(root, { recursive: true, force: true }); + } + }), +); + it.effect("preflight selects source before setup-vp install to keep the job dependency-free", () => Effect.gen(function* () { const text = yield* Effect.promise(() => readWorkflow("fork-release.yml"));