Files
moltbot/scripts/generate-dependency-release-evidence.mjs
Josh Avant bd4db5ee62 Add dependency release safety evidence and PR awareness (#81325)
* test: cover dependency pin guard

* build: add dependency vulnerability gate

* build: add dependency risk report

* build: add dependency drift reports

* build: include dependency ownership surface evidence

* build: rename dependency report commands

* build: respect release age exclusions in risk report

* build: clarify transitive risk accounting

* build: remove transitive risk exception registry

* build: clarify transitive risk signal wording

* ci: attach dependency evidence to release preflight

* ci: extract dependency release evidence generator

* build: rename ownership surface dependency report

* ci: clarify release evidence naming

* build: clarify recently published risk report

* build: reorder transitive risk report sections

* build: fix ownership surface pluralization

* ci: surface dependency changes on PRs

* ci: harden dependency change awareness

* ci: use dependency changed PR label

* build: fix dependency report lint

* docs: add dependency safety changelog
2026-05-13 03:05:09 -05:00

421 lines
12 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { appendFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
export const DEPENDENCY_EVIDENCE_REPORTS = [
{
name: "npm advisory vulnerability gate",
command: "pnpm deps:vuln:gate",
policy: "hard-blocking",
json: "dependency-vulnerability-gate.json",
markdown: "dependency-vulnerability-gate.md",
},
{
name: "Transitive manifest risk report",
command: "pnpm deps:transitive-risk:report",
policy: "report-only",
json: "transitive-manifest-risk-report.json",
markdown: "transitive-manifest-risk-report.md",
},
{
name: "Dependency ownership and install surface report",
command: "pnpm deps:ownership-surface:report",
policy: "report-only",
json: "dependency-ownership-surface-report.json",
markdown: "dependency-ownership-surface-report.md",
},
{
name: "Dependency change report",
command: "pnpm deps:changes:report",
policy: "report-only",
json: "dependency-changes-report.json",
markdown: "dependency-changes-report.md",
},
];
const RELEASE_TAG_PATTERN = "v[0-9]*.[0-9]*.[0-9]*";
function trimOutput(output) {
return String(output).trim();
}
function commandOutput(
command,
args,
{ rootDir, execFileSyncImpl = execFileSync, allowFailure = false },
) {
try {
return trimOutput(
execFileSyncImpl(command, args, {
cwd: rootDir,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
}),
);
} catch (error) {
if (allowFailure) {
return null;
}
throw error;
}
}
function runCommand(command, args, { rootDir, execFileSyncImpl = execFileSync }) {
execFileSyncImpl(command, args, {
cwd: rootDir,
stdio: "inherit",
});
}
export function resolveReleaseTag({ releaseRef, packageVersion }) {
if (/^[0-9a-fA-F]{40}$/u.test(releaseRef)) {
return `v${packageVersion}`;
}
return releaseRef;
}
export function resolvePreviousReleaseTag({
rootDir = process.cwd(),
execFileSyncImpl = execFileSync,
fetchOnMiss = true,
} = {}) {
const describeArgs = [
"describe",
"--tags",
"--match",
RELEASE_TAG_PATTERN,
"--abbrev=0",
"HEAD^",
];
const localTag = commandOutput("git", describeArgs, {
rootDir,
execFileSyncImpl,
allowFailure: true,
});
if (localTag) {
return localTag;
}
if (fetchOnMiss) {
runCommand("git", ["fetch", "--tags", "--force", "origin"], { rootDir, execFileSyncImpl });
}
const fetchedTag = commandOutput("git", describeArgs, {
rootDir,
execFileSyncImpl,
allowFailure: true,
});
if (fetchedTag) {
return fetchedTag;
}
throw new Error(
"Could not resolve a previous reachable release tag for dependency change evidence.",
);
}
export function createDependencyEvidenceManifest({
generatedAt = new Date().toISOString(),
releaseTag,
releaseRef,
releaseSha,
npmDistTag,
packageVersion,
workflowRunId = "",
workflowRunAttempt = "",
dependencyChangeBaseRef,
} = {}) {
return {
schemaVersion: 1,
generatedAt,
releaseTag,
releaseRef,
releaseSha,
npmDistTag,
packageName: "openclaw",
packageVersion,
workflowRunId,
workflowRunAttempt,
dependencyChangeBaseRef,
reports: DEPENDENCY_EVIDENCE_REPORTS,
};
}
function reportPath(evidenceDir, fileName) {
return path.join(evidenceDir, fileName);
}
async function readJson(filePath) {
return JSON.parse(await readFile(filePath, "utf8"));
}
export async function collectDependencyEvidenceSummaryCounts(evidenceDir) {
const [vulnerability, transitiveRisk, ownershipSurface, dependencyChanges] = await Promise.all([
readJson(reportPath(evidenceDir, "dependency-vulnerability-gate.json")),
readJson(reportPath(evidenceDir, "transitive-manifest-risk-report.json")),
readJson(reportPath(evidenceDir, "dependency-ownership-surface-report.json")),
readJson(reportPath(evidenceDir, "dependency-changes-report.json")),
]);
return {
vulnerabilityBlockers: vulnerability.blockers.length,
vulnerabilityFindings: vulnerability.findings.length,
transitiveRiskSignals: transitiveRisk.findingCount,
workspaceExcludedTransitiveSignals: transitiveRisk.workspaceExcludedFindingCount,
transitiveMetadataFailures: transitiveRisk.metadataFailures.length,
ownershipLockfilePackages: ownershipSurface.summary.lockfilePackageCount,
ownershipBuildRiskPackages: ownershipSurface.summary.buildRiskPackageCount,
dependencyFileChanges: dependencyChanges.summary.dependencyFileChanges,
dependencyAddedPackages: dependencyChanges.summary.addedPackages,
dependencyRemovedPackages: dependencyChanges.summary.removedPackages,
dependencyChangedPackages: dependencyChanges.summary.changedPackages,
};
}
export function renderDependencyEvidenceSummary({ releaseTag, releaseSha, baseRef, counts }) {
return `${[
"# Dependency release evidence",
"",
`Generated for \`${releaseTag}\` at \`${releaseSha}\`.`,
"",
"## Summary",
"",
`- npm advisory vulnerability hard blockers: ${counts.vulnerabilityBlockers}`,
`- npm advisory vulnerability total findings: ${counts.vulnerabilityFindings}`,
`- Transitive manifest reported risk signals: ${counts.transitiveRiskSignals}`,
`- Workspace-policy excluded transitive signals: ${counts.workspaceExcludedTransitiveSignals}`,
`- Transitive manifest metadata failures: ${counts.transitiveMetadataFailures}`,
`- Lockfile packages inspected for ownership/install surface: ${counts.ownershipLockfilePackages}`,
`- Packages with install-time or platform-specific behavior: ${counts.ownershipBuildRiskPackages}`,
`- Dependency change baseline: \`${baseRef}\``,
`- Dependency file changes: ${counts.dependencyFileChanges}`,
`- Resolved package changes: +${counts.dependencyAddedPackages} -${counts.dependencyRemovedPackages} changed ${counts.dependencyChangedPackages}`,
"",
"## Reports",
"",
"- `dependency-vulnerability-gate.md`",
"- `transitive-manifest-risk-report.md`",
"- `dependency-ownership-surface-report.md`",
"- `dependency-changes-report.md`",
].join("\n")}\n`;
}
export function renderDependencyEvidenceStepSummary({ evidenceArtifactName, baseRef, counts }) {
return `${[
"### Dependency release evidence",
"",
`- Evidence artifact: \`${evidenceArtifactName}\``,
`- Dependency change baseline: \`${baseRef}\``,
`- npm advisory vulnerability hard blockers: \`${counts.vulnerabilityBlockers}\``,
`- Transitive manifest reported risk signals: \`${counts.transitiveRiskSignals}\``,
`- Workspace-policy excluded transitive signals: \`${counts.workspaceExcludedTransitiveSignals}\``,
`- Ownership/install surface lockfile packages: \`${counts.ownershipLockfilePackages}\``,
`- Dependency file changes: \`${counts.dependencyFileChanges}\``,
`- Resolved package changes: \`+${counts.dependencyAddedPackages} -${counts.dependencyRemovedPackages} changed ${counts.dependencyChangedPackages}\``,
].join("\n")}\n`;
}
function runEvidenceReports({ rootDir, outputDir, baseRef, execFileSyncImpl }) {
runCommand(
"pnpm",
[
"deps:vuln:gate",
"--",
"--json",
reportPath(outputDir, "dependency-vulnerability-gate.json"),
"--markdown",
reportPath(outputDir, "dependency-vulnerability-gate.md"),
],
{ rootDir, execFileSyncImpl },
);
runCommand(
"pnpm",
[
"deps:transitive-risk:report",
"--",
"--json",
reportPath(outputDir, "transitive-manifest-risk-report.json"),
"--markdown",
reportPath(outputDir, "transitive-manifest-risk-report.md"),
],
{ rootDir, execFileSyncImpl },
);
runCommand(
"pnpm",
[
"deps:ownership-surface:report",
"--",
"--json",
reportPath(outputDir, "dependency-ownership-surface-report.json"),
"--markdown",
reportPath(outputDir, "dependency-ownership-surface-report.md"),
],
{ rootDir, execFileSyncImpl },
);
runCommand(
"pnpm",
[
"deps:changes:report",
"--",
"--base-ref",
baseRef,
"--json",
reportPath(outputDir, "dependency-changes-report.json"),
"--markdown",
reportPath(outputDir, "dependency-changes-report.md"),
],
{ rootDir, execFileSyncImpl },
);
}
export async function generateDependencyReleaseEvidence({
rootDir = process.cwd(),
outputDir,
releaseRef,
npmDistTag,
baseRef = null,
githubOutput = process.env.GITHUB_OUTPUT,
githubStepSummary = process.env.GITHUB_STEP_SUMMARY,
workflowRunId = process.env.GITHUB_RUN_ID ?? "",
workflowRunAttempt = process.env.GITHUB_RUN_ATTEMPT ?? "",
execFileSyncImpl = execFileSync,
now = new Date(),
} = {}) {
if (!outputDir) {
throw new Error("Expected --output-dir <path>.");
}
if (!releaseRef) {
throw new Error("Expected --release-ref <tag-or-sha>.");
}
if (!npmDistTag) {
throw new Error("Expected --npm-dist-tag <tag>.");
}
await rm(outputDir, { recursive: true, force: true });
await mkdir(outputDir, { recursive: true });
const releaseSha = commandOutput("git", ["rev-parse", "HEAD"], { rootDir, execFileSyncImpl });
const packageJson = await readJson(path.join(rootDir, "package.json"));
const packageVersion = packageJson.version;
const releaseTag = resolveReleaseTag({ releaseRef, packageVersion });
const dependencyChangeBaseRef =
baseRef ?? resolvePreviousReleaseTag({ rootDir, execFileSyncImpl });
runEvidenceReports({
rootDir,
outputDir,
baseRef: dependencyChangeBaseRef,
execFileSyncImpl,
});
const manifest = createDependencyEvidenceManifest({
generatedAt: now.toISOString(),
releaseTag,
releaseRef,
releaseSha,
npmDistTag,
packageVersion,
workflowRunId,
workflowRunAttempt,
dependencyChangeBaseRef,
});
await writeFile(
reportPath(outputDir, "dependency-evidence-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
const counts = await collectDependencyEvidenceSummaryCounts(outputDir);
await writeFile(
reportPath(outputDir, "dependency-evidence-summary.md"),
renderDependencyEvidenceSummary({
releaseTag,
releaseSha,
baseRef: dependencyChangeBaseRef,
counts,
}),
"utf8",
);
if (githubStepSummary) {
await appendFile(
githubStepSummary,
renderDependencyEvidenceStepSummary({
evidenceArtifactName: `openclaw-release-dependency-evidence-${releaseRef}`,
baseRef: dependencyChangeBaseRef,
counts,
}),
"utf8",
);
}
if (githubOutput) {
await appendFile(githubOutput, `dir=${outputDir}\n`, "utf8");
}
return { manifest, counts, outputDir };
}
function parseArgs(argv) {
const options = {
rootDir: process.cwd(),
outputDir: null,
releaseRef: null,
npmDistTag: null,
baseRef: null,
githubOutput: process.env.GITHUB_OUTPUT,
githubStepSummary: process.env.GITHUB_STEP_SUMMARY,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--") {
continue;
}
if (arg === "--root") {
options.rootDir = argv[++index];
continue;
}
if (arg === "--output-dir") {
options.outputDir = argv[++index];
continue;
}
if (arg === "--release-ref") {
options.releaseRef = argv[++index];
continue;
}
if (arg === "--npm-dist-tag") {
options.npmDistTag = argv[++index];
continue;
}
if (arg === "--base-ref") {
options.baseRef = argv[++index];
continue;
}
if (arg === "--github-output") {
options.githubOutput = argv[++index];
continue;
}
if (arg === "--github-step-summary") {
options.githubStepSummary = argv[++index];
continue;
}
throw new Error(`Unsupported argument: ${arg}`);
}
return options;
}
export async function main(argv = process.argv.slice(2)) {
await generateDependencyReleaseEvidence(parseArgs(argv));
return 0;
}
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(import.meta.filename)) {
main().then(
(exitCode) => {
process.exitCode = exitCode;
},
(error) => {
process.stderr.write(`${error.stack ?? error.message ?? String(error)}\n`);
process.exitCode = 1;
},
);
}