Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ jobs:
source: contract
contract-path: ota.yaml
annotate: false
comment-pr: false
artifact-name: ota-smoke-${{ matrix.os }}

release:
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
135 changes: 130 additions & 5 deletions dist/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
40 changes: 35 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import {
parseOtaPayload,
parsePositiveInteger,
proofArtifactPaths,
receiptArchiveClosureFiles,
receiptArchivePayloadForUpload,
resolveBootstrapSourceFromContract,
resolveOtaInstallPlan,
runUrlFromEnv,
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
Loading
Loading