diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa8230d..9b1c52f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,7 @@ jobs: source: contract contract-path: ota.yaml annotate: false + comment-pr: false artifact-name: ota-smoke-${{ matrix.os }} release: diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d3b31e..64fdde8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,10 @@ ## Unreleased +- preserve complete archived receipt closures in workflow artifacts by including every declared + local receipt artifact and refusing incomplete historical baselines before comparison; a fresh + current receipt is archived when no reusable baseline remains + - drift-only gates now annotate only contract-to-CI drift findings when `fail-on-error: false`, keeping unrelated Doctor readiness findings out of a passing gate. diff --git a/README.md b/README.md index f6d715f..d07d2de 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,8 @@ You can replace `patch` with `minor`, `major`, `prerelease`, or an explicit semv - writes a GitHub Actions step summary - emits GitHub annotations from Ota findings - posts or updates a sticky pull request comment by default -- uploads the ota JSON output plus any archived receipt or runtime-proof artifacts as workflow artifacts +- uploads the ota JSON output plus complete archived receipt closures (the receipt and its + referenced contract snapshot) or runtime-proof artifacts as workflow artifacts - formats summaries and sticky pull request comments around outcome, primary blocker or change, next steps, and receipt or baseline references ## Requirements diff --git a/dist/index.js b/dist/index.js index aa28d8b..8914b7c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -130419,6 +130419,103 @@ function artifactFiles(outputPath, archivePath) { return files; } +function receiptArchiveClosurePaths(payload, cwd, pathModule = external_node_path_) { + if (!payload || payload.mode !== "receipt" || !payload.receipt) { + return []; + } + + const snapshotRef = payload.receipt.contract_snapshot_ref; + if (typeof snapshotRef !== "string" || snapshotRef.trim() === "") { + throw new Error("archived receipt does not declare immutable `receipt.contract_snapshot_ref`"); + } + + const references = [snapshotRef]; + for (const route of Array.isArray(payload.artifact_routing) ? payload.artifact_routing : []) { + if (route?.path === undefined || route.path === null || route.path === "") { + continue; + } + if (typeof route.path !== "string") { + throw new Error("receipt artifact route path must be a string"); + } + references.push(route.path); + } + + const root = pathModule.resolve(cwd); + const paths = []; + for (const reference of references) { + const resolved = pathModule.resolve(root, reference); + const relative = pathModule.relative(root, resolved); + if ( + relative === "" + || relative === ".." + || relative.startsWith(`..${pathModule.sep}`) + || pathModule.isAbsolute(relative) + ) { + throw new Error(`receipt artifact path escapes the working directory: ${reference}`); + } + paths.push(resolved); + } + + return [...new Set(paths)]; +} + +function receiptArchivePayloadForUpload(archivePath, payload) { + if (!archivePath) { + return null; + } + if (!payload || payload.mode !== "receipt") { + throw new Error("an archived receipt requires the current receipt payload"); + } + return payload; +} + +async function receiptArchiveClosureFiles(payload, cwd, fileSystem, pathModule = external_node_path_) { + if (typeof fileSystem?.lstat !== "function" || typeof fileSystem.realpath !== "function") { + throw new Error("receipt archive closure requires lstat and realpath functions"); + } + + const files = receiptArchiveClosurePaths(payload, cwd, pathModule); + const root = pathModule.resolve(cwd); + let canonicalRoot; + try { + canonicalRoot = await fileSystem.realpath(root); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`receipt archive root cannot be resolved \`${root}\`: ${detail}`); + } + + for (const file of files) { + let metadata; + try { + metadata = await fileSystem.lstat(file); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`archived receipt closure is missing \`${file}\`: ${detail}`); + } + if (!metadata.isFile()) { + throw new Error(`archived receipt closure path is not a regular file: ${file}`); + } + + let canonicalFile; + try { + canonicalFile = await fileSystem.realpath(file); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`archived receipt closure cannot be resolved \`${file}\`: ${detail}`); + } + const relative = pathModule.relative(canonicalRoot, canonicalFile); + if ( + relative === "" + || relative === ".." + || relative.startsWith(`..${pathModule.sep}`) + || pathModule.isAbsolute(relative) + ) { + throw new Error(`receipt artifact path resolves outside the working directory: ${file}`); + } + } + return files; +} + function proofArtifactPaths(payload, cwd, pathModule = external_node_path_) { if (!payload || payload.mode !== "runtime-proof" || !payload.artifacts || typeof payload.artifacts !== "object") { return []; @@ -131156,7 +131253,7 @@ async function selectReceiptBaselineFile(root) { } const archived = candidates.find(({ file }) => file.includes(`${external_node_path_.sep}.ota${external_node_path_.sep}receipts${external_node_path_.sep}`)); - return archived?.file || candidates[0].file; + return archived || candidates[0]; } async function restoreBaselineArtifact(artifactName, token, cwd) { @@ -131196,15 +131293,25 @@ async function restoreBaselineArtifact(artifactName, token, cwd) { const downloadPath = await promises_.mkdtemp(external_node_path_.join(process.env.RUNNER_TEMP || cwd, "ota-baseline-")); await client.downloadArtifact(artifact.id, { path: downloadPath, findBy }); - const baselinePath = await selectReceiptBaselineFile(downloadPath); + const baseline = await selectReceiptBaselineFile(downloadPath); - if (!baselinePath) { + if (!baseline) { notice(`Artifact \`${artifactName}\` from run ${workflowRunId} did not contain a reusable receipt baseline; running without a restored baseline`); return ""; } + try { + await receiptArchiveClosureFiles(baseline.payload, downloadPath, promises_); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + notice( + `Artifact \`${artifactName}\` from run ${workflowRunId} did not contain a reusable receipt closure: ${detail}; running without a restored baseline` + ); + return ""; + } + info(`Using baseline receipt from artifact \`${artifactName}\` in successful ${workflowFile} run ${workflowRunId}`); - return baselinePath; + return baseline.file; } async function runOtaInvocation(otaBinary, inputs, cwd) { @@ -131367,6 +131474,8 @@ async function main() { let commandLine; let selectedResult; let archivePath = ""; + let archivedReceiptPayload; + let receiptArchiveDependencyFiles = []; let proofArtifactFiles = []; if (inputs.command === "receipt" && (baselinePath || effectiveBaselineArtifactName)) { @@ -131381,6 +131490,7 @@ async function main() { cwd ); const currentPayload = parseOtaPayload(currentRun.result.stdout); + archivedReceiptPayload = currentPayload; archivePath = normalizeArchivePath( typeof currentPayload.archive_path === "string" ? currentPayload.archive_path : "", cwd @@ -131417,6 +131527,7 @@ async function main() { cwd ); const currentPayload = parseOtaPayload(currentRun.result.stdout); + archivedReceiptPayload = currentPayload; archivePath = normalizeArchivePath( typeof currentPayload.archive_path === "string" ? currentPayload.archive_path : "", cwd @@ -131434,6 +131545,7 @@ async function main() { cwd ); const currentPayload = parseOtaPayload(currentRun.result.stdout); + archivedReceiptPayload = currentPayload; archivePath = normalizeArchivePath( typeof currentPayload.archive_path === "string" ? currentPayload.archive_path : "", cwd @@ -131452,6 +131564,7 @@ async function main() { cwd ); const currentPayload = parseOtaPayload(currentRun.result.stdout); + archivedReceiptPayload = currentPayload; archivePath = normalizeArchivePath( typeof currentPayload.archive_path === "string" ? currentPayload.archive_path : "", cwd @@ -131463,6 +131576,7 @@ async function main() { } else { const run = await runOtaInvocation(otaBinary, inputs, cwd); payload = parseOtaPayload(run.result.stdout); + archivedReceiptPayload = payload; commandLine = run.commandLine; selectedResult = run.result; archivePath = normalizeArchivePath( @@ -131472,6 +131586,11 @@ async function main() { proofArtifactFiles = proofArtifactPaths(payload, cwd); } + const receiptArtifactPayload = receiptArchivePayloadForUpload(archivePath, archivedReceiptPayload); + if (receiptArtifactPayload) { + receiptArchiveDependencyFiles = await receiptArchiveClosureFiles(receiptArtifactPayload, cwd, promises_); + } + await promises_.writeFile(outputPath, selectedResult.stdout, "utf8"); const kind = inferKind(payload); @@ -131567,7 +131686,13 @@ async function main() { await summary_summary.addRaw(summaryMarkdown, true).write(); - const files = [...new Set([...artifactFiles(outputPath, archivePath), ...proofArtifactFiles])]; + const files = [ + ...new Set([ + ...artifactFiles(outputPath, archivePath), + ...receiptArchiveDependencyFiles, + ...proofArtifactFiles + ]) + ]; const retentionDays = parsePositiveInteger(inputs.artifactRetentionDays, undefined); await uploadArtifacts(artifactName, files, retentionDays); diff --git a/package-lock.json b/package-lock.json index 45bbcaa..b3cd64b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@ota-run/action", - "version": "1.0.15", + "version": "1.0.16", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@ota-run/action", - "version": "1.0.15", + "version": "1.0.16", "dependencies": { "@actions/artifact": "^6.2.1", "@actions/core": "^3.0.0", diff --git a/package.json b/package.json index 19c8743..9f60167 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ota-run/action", - "version": "1.0.15", + "version": "1.0.16", "private": true, "description": "Official GitHub Action for Ota", "type": "module", diff --git a/src/index.js b/src/index.js index 0e0ff58..1d792dc 100644 --- a/src/index.js +++ b/src/index.js @@ -57,6 +57,8 @@ import { parseOtaPayload, parsePositiveInteger, proofArtifactPaths, + receiptArchiveClosureFiles, + receiptArchivePayloadForUpload, resolveBootstrapSourceFromContract, resolveOtaInstallPlan, runUrlFromEnv, @@ -455,7 +457,7 @@ async function selectReceiptBaselineFile(root) { } const archived = candidates.find(({ file }) => file.includes(`${path.sep}.ota${path.sep}receipts${path.sep}`)); - return archived?.file || candidates[0].file; + return archived || candidates[0]; } async function restoreBaselineArtifact(artifactName, token, cwd) { @@ -495,15 +497,25 @@ async function restoreBaselineArtifact(artifactName, token, cwd) { const downloadPath = await fs.mkdtemp(path.join(process.env.RUNNER_TEMP || cwd, "ota-baseline-")); await client.downloadArtifact(artifact.id, { path: downloadPath, findBy }); - const baselinePath = await selectReceiptBaselineFile(downloadPath); + const baseline = await selectReceiptBaselineFile(downloadPath); - if (!baselinePath) { + if (!baseline) { core.notice(`Artifact \`${artifactName}\` from run ${workflowRunId} did not contain a reusable receipt baseline; running without a restored baseline`); return ""; } + try { + await receiptArchiveClosureFiles(baseline.payload, downloadPath, fs); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + core.notice( + `Artifact \`${artifactName}\` from run ${workflowRunId} did not contain a reusable receipt closure: ${detail}; running without a restored baseline` + ); + return ""; + } + core.info(`Using baseline receipt from artifact \`${artifactName}\` in successful ${workflowFile} run ${workflowRunId}`); - return baselinePath; + return baseline.file; } async function runOtaInvocation(otaBinary, inputs, cwd) { @@ -666,6 +678,8 @@ async function main() { let commandLine; let selectedResult; let archivePath = ""; + let archivedReceiptPayload; + let receiptArchiveDependencyFiles = []; let proofArtifactFiles = []; if (inputs.command === "receipt" && (baselinePath || effectiveBaselineArtifactName)) { @@ -680,6 +694,7 @@ async function main() { cwd ); const currentPayload = parseOtaPayload(currentRun.result.stdout); + archivedReceiptPayload = currentPayload; archivePath = normalizeArchivePath( typeof currentPayload.archive_path === "string" ? currentPayload.archive_path : "", cwd @@ -716,6 +731,7 @@ async function main() { cwd ); const currentPayload = parseOtaPayload(currentRun.result.stdout); + archivedReceiptPayload = currentPayload; archivePath = normalizeArchivePath( typeof currentPayload.archive_path === "string" ? currentPayload.archive_path : "", cwd @@ -733,6 +749,7 @@ async function main() { cwd ); const currentPayload = parseOtaPayload(currentRun.result.stdout); + archivedReceiptPayload = currentPayload; archivePath = normalizeArchivePath( typeof currentPayload.archive_path === "string" ? currentPayload.archive_path : "", cwd @@ -751,6 +768,7 @@ async function main() { cwd ); const currentPayload = parseOtaPayload(currentRun.result.stdout); + archivedReceiptPayload = currentPayload; archivePath = normalizeArchivePath( typeof currentPayload.archive_path === "string" ? currentPayload.archive_path : "", cwd @@ -762,6 +780,7 @@ async function main() { } else { const run = await runOtaInvocation(otaBinary, inputs, cwd); payload = parseOtaPayload(run.result.stdout); + archivedReceiptPayload = payload; commandLine = run.commandLine; selectedResult = run.result; archivePath = normalizeArchivePath( @@ -771,6 +790,11 @@ async function main() { proofArtifactFiles = proofArtifactPaths(payload, cwd); } + const receiptArtifactPayload = receiptArchivePayloadForUpload(archivePath, archivedReceiptPayload); + if (receiptArtifactPayload) { + receiptArchiveDependencyFiles = await receiptArchiveClosureFiles(receiptArtifactPayload, cwd, fs); + } + await fs.writeFile(outputPath, selectedResult.stdout, "utf8"); const kind = inferKind(payload); @@ -866,7 +890,13 @@ async function main() { await core.summary.addRaw(summaryMarkdown, true).write(); - const files = [...new Set([...artifactFiles(outputPath, archivePath), ...proofArtifactFiles])]; + const files = [ + ...new Set([ + ...artifactFiles(outputPath, archivePath), + ...receiptArchiveDependencyFiles, + ...proofArtifactFiles + ]) + ]; const retentionDays = parsePositiveInteger(inputs.artifactRetentionDays, undefined); await uploadArtifacts(artifactName, files, retentionDays); diff --git a/src/lib.js b/src/lib.js index 331140c..118a9f7 100644 --- a/src/lib.js +++ b/src/lib.js @@ -697,6 +697,103 @@ function artifactFiles(outputPath, archivePath) { return files; } +function receiptArchiveClosurePaths(payload, cwd, pathModule = path) { + if (!payload || payload.mode !== "receipt" || !payload.receipt) { + return []; + } + + const snapshotRef = payload.receipt.contract_snapshot_ref; + if (typeof snapshotRef !== "string" || snapshotRef.trim() === "") { + throw new Error("archived receipt does not declare immutable `receipt.contract_snapshot_ref`"); + } + + const references = [snapshotRef]; + for (const route of Array.isArray(payload.artifact_routing) ? payload.artifact_routing : []) { + if (route?.path === undefined || route.path === null || route.path === "") { + continue; + } + if (typeof route.path !== "string") { + throw new Error("receipt artifact route path must be a string"); + } + references.push(route.path); + } + + const root = pathModule.resolve(cwd); + const paths = []; + for (const reference of references) { + const resolved = pathModule.resolve(root, reference); + const relative = pathModule.relative(root, resolved); + if ( + relative === "" + || relative === ".." + || relative.startsWith(`..${pathModule.sep}`) + || pathModule.isAbsolute(relative) + ) { + throw new Error(`receipt artifact path escapes the working directory: ${reference}`); + } + paths.push(resolved); + } + + return [...new Set(paths)]; +} + +function receiptArchivePayloadForUpload(archivePath, payload) { + if (!archivePath) { + return null; + } + if (!payload || payload.mode !== "receipt") { + throw new Error("an archived receipt requires the current receipt payload"); + } + return payload; +} + +async function receiptArchiveClosureFiles(payload, cwd, fileSystem, pathModule = path) { + if (typeof fileSystem?.lstat !== "function" || typeof fileSystem.realpath !== "function") { + throw new Error("receipt archive closure requires lstat and realpath functions"); + } + + const files = receiptArchiveClosurePaths(payload, cwd, pathModule); + const root = pathModule.resolve(cwd); + let canonicalRoot; + try { + canonicalRoot = await fileSystem.realpath(root); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`receipt archive root cannot be resolved \`${root}\`: ${detail}`); + } + + for (const file of files) { + let metadata; + try { + metadata = await fileSystem.lstat(file); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`archived receipt closure is missing \`${file}\`: ${detail}`); + } + if (!metadata.isFile()) { + throw new Error(`archived receipt closure path is not a regular file: ${file}`); + } + + let canonicalFile; + try { + canonicalFile = await fileSystem.realpath(file); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`archived receipt closure cannot be resolved \`${file}\`: ${detail}`); + } + const relative = pathModule.relative(canonicalRoot, canonicalFile); + if ( + relative === "" + || relative === ".." + || relative.startsWith(`..${pathModule.sep}`) + || pathModule.isAbsolute(relative) + ) { + throw new Error(`receipt artifact path resolves outside the working directory: ${file}`); + } + } + return files; +} + function proofArtifactPaths(payload, cwd, pathModule = path) { if (!payload || payload.mode !== "runtime-proof" || !payload.artifacts || typeof payload.artifacts !== "object") { return []; @@ -1037,6 +1134,9 @@ export { parsePositiveInteger, proofArtifactPaths, pushBaselineProvenanceLines, + receiptArchiveClosureFiles, + receiptArchiveClosurePaths, + receiptArchivePayloadForUpload, resolveBootstrapSourceFromContract, resolveOtaInstallPlan, runUrlFromEnv, diff --git a/test/lib.test.js b/test/lib.test.js index 3bb3b2f..b816db0 100644 --- a/test/lib.test.js +++ b/test/lib.test.js @@ -49,6 +49,9 @@ import { prioritizeRuntimeNodePath, parseInstallMode, parseOtaPayload, + receiptArchiveClosureFiles, + receiptArchiveClosurePaths, + receiptArchivePayloadForUpload, resolveBootstrapSourceFromContract, resolveOtaInstallPlan, selectPullRequestNumberForComment, @@ -122,6 +125,109 @@ test("defaultBaselineArtifactName auto-selects the current artifact on pull requ }), ""); }); +test("receipt archive closure keeps every declared local evidence path", async () => { + const payload = { + mode: "receipt", + receipt: { + contract_snapshot_ref: "./.ota/contracts/sha256-contract.json" + }, + artifact_routing: [ + { role: "keep", kind: "receipt_archive", stage_family: "receipt", path: "./.ota/receipts/current.json" }, + { role: "inspect", kind: "contract_snapshot", stage_family: "receipt", path: "./.ota/contracts/sha256-contract.json" } + ] + }; + const expected = [ + "/workspace/repo/.ota/contracts/sha256-contract.json", + "/workspace/repo/.ota/receipts/current.json" + ]; + + assert.deepEqual(receiptArchiveClosurePaths(payload, "/workspace/repo"), expected); + assert.deepEqual( + await receiptArchiveClosureFiles(payload, "/workspace/repo", { + lstat: async () => ({ isFile: () => true }), + realpath: async (file) => file + }), + expected + ); +}); + +test("receipt archive upload keeps the current receipt when a later diff becomes selected output", () => { + const currentReceipt = { + mode: "receipt", + receipt: { + contract_snapshot_ref: "./.ota/contracts/current.json" + } + }; + const selectedDiff = { + mode: "receipt_diff", + current: currentReceipt + }; + + assert.equal(receiptArchivePayloadForUpload("/workspace/repo/.ota/receipts/current.json", currentReceipt), currentReceipt); + assert.equal(receiptArchivePayloadForUpload("", selectedDiff), null); + assert.throws( + () => receiptArchivePayloadForUpload("/workspace/repo/.ota/receipts/current.json", selectedDiff), + /requires the current receipt payload/ + ); +}); + +test("receipt archive closure refuses incomplete, unsafe, or non-file dependencies", async () => { + assert.throws( + () => receiptArchiveClosurePaths({ mode: "receipt", receipt: {} }, "/workspace/repo"), + /does not declare immutable `receipt\.contract_snapshot_ref`/ + ); + assert.throws( + () => receiptArchiveClosurePaths({ + mode: "receipt", + receipt: { contract_snapshot_ref: "../outside.json" } + }, "/workspace/repo"), + /escapes the working directory/ + ); + await assert.rejects( + receiptArchiveClosureFiles({ + mode: "receipt", + receipt: { contract_snapshot_ref: "./.ota/contracts/missing.json" } + }, "/workspace/repo", { + lstat: async () => { + throw new Error("ENOENT"); + }, + realpath: async (file) => file + }), + /closure is missing/ + ); + await assert.rejects( + receiptArchiveClosureFiles({ + mode: "receipt", + receipt: { contract_snapshot_ref: "./.ota/contracts/not-a-file" } + }, "/workspace/repo", { + lstat: async () => ({ isFile: () => false }), + realpath: async (file) => file + }), + /not a regular file/ + ); +}); + +test("receipt archive closure refuses a symlinked ancestor outside the working directory", { + skip: process.platform === "win32" +}, async (t) => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "ota-action-closure-root-")); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "ota-action-closure-outside-")); + t.after(async () => { + await Promise.all([fs.rm(root, { recursive: true, force: true }), fs.rm(outside, { recursive: true, force: true })]); + }); + await fs.mkdir(path.join(root, ".ota")); + await fs.writeFile(path.join(outside, "snapshot.json"), "{}", "utf8"); + await fs.symlink(outside, path.join(root, ".ota", "contracts"), "dir"); + + await assert.rejects( + receiptArchiveClosureFiles({ + mode: "receipt", + receipt: { contract_snapshot_ref: "./.ota/contracts/snapshot.json" } + }, root, fs), + /resolves outside the working directory/ + ); +}); + test("buildOtaArgs forwards receipt baseline diff gate flags", () => { const args = buildOtaArgs({ command: "receipt",