mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
414 lines
16 KiB
JavaScript
414 lines
16 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import {
|
|
downloadExactActionsArtifactArchive,
|
|
inspectActionsArtifactZipWithPolicy,
|
|
readBoundedRegularFile,
|
|
validateActionsArtifactBinding,
|
|
validateActionsArtifactProducerJob,
|
|
} from "./lib/actions-artifact-archive.mjs";
|
|
import { collectPublishablePluginPackages } from "./lib/plugin-npm-release.ts";
|
|
import { isRecord } from "./lib/record-shared.mjs";
|
|
import {
|
|
verifyPreparedNpmRegistry,
|
|
verifyPublishedNpmRegistry,
|
|
} from "./plugin-npm-prepared-release.mjs";
|
|
import { verifyPluginPublicationArtifact } from "./plugin-publication-artifact.mjs";
|
|
import { runReleaseToolingGh } from "./release-tooling-identity.mjs";
|
|
|
|
const WORKFLOW = ".github/workflows/plugin-npm-release.yml";
|
|
const JOB_PREFIX = "Publish plugin npm package (";
|
|
const SHA = /^[a-f0-9]{40}$/u;
|
|
const RECEIPT_BYTES = 256 * 1024;
|
|
|
|
function requireValue(condition, message) {
|
|
if (!condition) {
|
|
throw new Error(message);
|
|
}
|
|
}
|
|
|
|
export async function createPluginNpmPublicationReadback(options) {
|
|
const { repository, runId, sourceSha, workflowSha, workflowRef, sourceRoot, cacheDir } = options;
|
|
requireValue(
|
|
/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repository) &&
|
|
Number.isSafeInteger(runId) &&
|
|
runId > 0 &&
|
|
SHA.test(sourceSha) &&
|
|
SHA.test(workflowSha) &&
|
|
typeof workflowRef === "string" &&
|
|
workflowRef.length > 0,
|
|
"Plugin npm parent readback requires the exact source, tooling, and child run.",
|
|
);
|
|
const runGh = options.runGh ?? runReleaseToolingGh;
|
|
const api = (endpoint) => JSON.parse(runGh(["api", `repos/${repository}/${endpoint}`]));
|
|
const inventory = (endpoint, key) => {
|
|
const fields =
|
|
key === "jobs"
|
|
? "id,name,run_id,run_attempt,head_sha,status,conclusion,steps"
|
|
: "id,name,digest,size_in_bytes,expired,expires_at,workflow_run";
|
|
const raw = runGh([
|
|
"api",
|
|
`repos/${repository}/${endpoint}${endpoint.includes("?") ? "&" : "?"}per_page=100`,
|
|
"--paginate",
|
|
"--jq",
|
|
`{total_count, ${key}: [.${key}[] | {${fields}}]} | @json`,
|
|
]);
|
|
const pages = raw
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line));
|
|
const count = pages[0]?.total_count;
|
|
requireValue(
|
|
Number.isSafeInteger(count) &&
|
|
count >= 0 &&
|
|
count <= 10_000 &&
|
|
pages.every((page) => page.total_count === count && Array.isArray(page[key])),
|
|
`Incomplete plugin npm ${key} inventory.`,
|
|
);
|
|
const entries = pages.flatMap((page) => page[key]);
|
|
requireValue(
|
|
entries.length === count && new Set(entries.map((entry) => entry.id)).size === count,
|
|
`Incomplete or duplicate plugin npm ${key} inventory.`,
|
|
);
|
|
return entries;
|
|
};
|
|
const run = api(`actions/runs/${runId}`);
|
|
requireValue(
|
|
run.id === runId &&
|
|
Number.isSafeInteger(run.run_attempt) &&
|
|
run.run_attempt > 0 &&
|
|
run.path === WORKFLOW &&
|
|
run.event === "workflow_dispatch" &&
|
|
run.head_sha === workflowSha &&
|
|
run.head_branch === workflowRef &&
|
|
run.repository?.full_name === repository &&
|
|
run.head_repository?.full_name === repository &&
|
|
run.status === "completed" &&
|
|
run.conclusion === "success",
|
|
"Plugin npm readback child identity or successful attempt does not match publication.",
|
|
);
|
|
const jobs = inventory(`actions/runs/${runId}/jobs?filter=all`, "jobs");
|
|
const artifacts = inventory(`actions/runs/${runId}/artifacts`, "artifacts");
|
|
const packages = collectPublishablePluginPackages(sourceRoot, {
|
|
packageNames: options.plugins?.length > 0 ? options.plugins : undefined,
|
|
});
|
|
const byJob = new Map(packages.map((entry) => [`${JOB_PREFIX}${entry.packageName})`, entry]));
|
|
const publishers = new Map();
|
|
for (const job of jobs) {
|
|
if (!job.name?.startsWith(JOB_PREFIX) || job.conclusion === "skipped") {
|
|
continue;
|
|
}
|
|
requireValue(
|
|
byJob.has(job.name) &&
|
|
job.run_id === runId &&
|
|
job.head_sha === workflowSha &&
|
|
Number.isSafeInteger(job.run_attempt) &&
|
|
job.run_attempt > 0 &&
|
|
job.run_attempt <= run.run_attempt,
|
|
"Plugin npm publisher job does not match the frozen package roster or child identity.",
|
|
);
|
|
const previous = publishers.get(job.name);
|
|
requireValue(previous?.run_attempt !== job.run_attempt, "Ambiguous plugin npm publisher job.");
|
|
if (!previous || previous.run_attempt < job.run_attempt) {
|
|
publishers.set(job.name, job);
|
|
}
|
|
}
|
|
const planners = jobs.filter((job) => job.name === "preview_plugins_npm");
|
|
const planner = planners.toSorted((left, right) => right.run_attempt - left.run_attempt)[0];
|
|
requireValue(
|
|
planner &&
|
|
planner.run_id === runId &&
|
|
planner.head_sha === workflowSha &&
|
|
planner.status === "completed" &&
|
|
planner.conclusion === "success" &&
|
|
Number.isSafeInteger(planner.run_attempt) &&
|
|
planner.run_attempt > 0 &&
|
|
planner.run_attempt <= run.run_attempt &&
|
|
planners.filter((job) => job.run_attempt === planner.run_attempt).length === 1,
|
|
"Plugin npm child has no unambiguous successful planning job.",
|
|
);
|
|
mkdirSync(cacheDir, { recursive: true });
|
|
const attempts = new Map([[`${runId}/${run.run_attempt}`, run]]);
|
|
const jobInventories = new Map();
|
|
const attempt = (id, number) => {
|
|
const key = `${id}/${number}`;
|
|
if (!attempts.has(key)) {
|
|
attempts.set(key, api(`actions/runs/${id}/attempts/${number}`));
|
|
}
|
|
return attempts.get(key);
|
|
};
|
|
const download = async (binding, metadata, producerRun, maxArchiveBytes) => {
|
|
validateActionsArtifactBinding({
|
|
expected: binding,
|
|
artifactMetadata: metadata,
|
|
workflowRun: producerRun,
|
|
});
|
|
let workflowJobs;
|
|
if (binding.runStatePolicy === "same-run-producer-success") {
|
|
const key = `${binding.runId}/${binding.runAttempt}`;
|
|
// Prior attempts are terminal; reuse their complete inventory across packages.
|
|
if (!jobInventories.has(key)) {
|
|
const producerJobs = inventory(
|
|
`actions/runs/${binding.runId}/attempts/${binding.runAttempt}/jobs`,
|
|
"jobs",
|
|
);
|
|
jobInventories.set(key, { total_count: producerJobs.length, jobs: producerJobs });
|
|
}
|
|
workflowJobs = jobInventories.get(key);
|
|
}
|
|
if (workflowJobs) {
|
|
validateActionsArtifactProducerJob({ expected: binding, workflowJobs });
|
|
}
|
|
const archivePath = join(cacheDir, `${binding.artifactId}.zip`);
|
|
const downloaded = await downloadExactActionsArtifactArchive({
|
|
expected: { ...binding, artifactExpiresAt: metadata.expires_at },
|
|
archivePath,
|
|
token: options.token,
|
|
fetchImpl: options.fetchImpl,
|
|
maxArchiveBytes,
|
|
});
|
|
return { ...downloaded, archivePath, workflowJobs };
|
|
};
|
|
const readJobReceipt = async (job, name, file) => {
|
|
const matches = artifacts.filter(
|
|
(artifact) => artifact.name === name && artifact.expired === false,
|
|
);
|
|
requireValue(matches.length === 1, `Expected one consumed npm qualification receipt: ${name}.`);
|
|
const metadata = matches[0];
|
|
const receiptBinding = {
|
|
repository,
|
|
workflowPath: WORKFLOW,
|
|
workflowEvent: "workflow_dispatch",
|
|
workflowSha,
|
|
workflowHeadBranch: workflowRef,
|
|
runId,
|
|
runAttempt: job.run_attempt,
|
|
runStatePolicy:
|
|
job.run_attempt === run.run_attempt ? "completed-success" : "same-run-producer-success",
|
|
consumerRunAttempt: run.run_attempt,
|
|
producerJobName: job.name,
|
|
...(job.conclusion === "failure"
|
|
? { producerStepName: "Upload consumed npm qualification" }
|
|
: {}),
|
|
artifactId: metadata.id,
|
|
artifactName: name,
|
|
artifactDigest: metadata.digest,
|
|
artifactSizeBytes: metadata.size_in_bytes,
|
|
};
|
|
const receiptArchive = await download(
|
|
receiptBinding,
|
|
metadata,
|
|
attempt(runId, job.run_attempt),
|
|
RECEIPT_BYTES,
|
|
);
|
|
const files = inspectActionsArtifactZipWithPolicy(receiptArchive.archiveBytes, {
|
|
expectedEntries: [file],
|
|
maxArchiveBytes: RECEIPT_BYTES,
|
|
maxExpandedBytes: RECEIPT_BYTES,
|
|
maxEntryBytes: () => RECEIPT_BYTES,
|
|
});
|
|
return { value: JSON.parse(files.get(file).toString("utf8")), metadata };
|
|
};
|
|
// Failed-job reruns retain the successful planner from its original attempt.
|
|
// Its resolved matrix, not absent publisher jobs, owns the skip disposition.
|
|
const { value: plan, metadata: planMetadata } = await readJobReceipt(
|
|
planner,
|
|
`plugin-npm-plan-${runId}-${planner.run_attempt}`,
|
|
"npm-publication-plan.json",
|
|
);
|
|
const identities = (entries) => {
|
|
requireValue(Array.isArray(entries) && entries.length <= 256, "Invalid npm planning roster.");
|
|
const values = entries.map((entry) =>
|
|
JSON.stringify([entry.packageName, entry.packageDir, entry.version]),
|
|
);
|
|
requireValue(new Set(values).size === values.length, "Duplicate npm planning roster entry.");
|
|
return values.toSorted();
|
|
};
|
|
requireValue(
|
|
plan.sourceSha === sourceSha &&
|
|
JSON.stringify(identities(plan.all)) === JSON.stringify(identities(packages)) &&
|
|
JSON.stringify(identities([...plan.candidates, ...plan.skippedPublished])) ===
|
|
JSON.stringify(identities(packages)),
|
|
"Resolved npm publication plan differs from the frozen package roster.",
|
|
);
|
|
requireValue(
|
|
plan.candidates.every((entry) => publishers.has(`${JOB_PREFIX}${entry.packageName})`)),
|
|
"Plugin npm planned candidate has no successful publisher job.",
|
|
);
|
|
const skipped = new Set(plan.skippedPublished.map((entry) => entry.packageName));
|
|
for (const job of publishers.values()) {
|
|
const skippedAfterFailure =
|
|
job.conclusion === "failure" &&
|
|
job.run_attempt < planner.run_attempt &&
|
|
skipped.has(byJob.get(job.name).packageName);
|
|
requireValue(
|
|
job.status === "completed" && (job.conclusion === "success" || skippedAfterFailure),
|
|
"Plugin npm publisher job did not complete successfully or precede its verified skipped plan.",
|
|
);
|
|
}
|
|
const evidence = [];
|
|
return {
|
|
evidence,
|
|
async verify(packageName, releaseVersion, releaseDistTag) {
|
|
const entry = byJob.get(`${JOB_PREFIX}${packageName})`);
|
|
requireValue(
|
|
entry?.version === releaseVersion,
|
|
"Qualified plugin version differs from the parent release version.",
|
|
);
|
|
const job = publishers.get(`${JOB_PREFIX}${packageName})`);
|
|
if (!job) {
|
|
requireValue(
|
|
skipped.has(packageName),
|
|
"Plugin npm package has no recorded publication disposition.",
|
|
);
|
|
// A prior parent may have published successfully and then failed before
|
|
// final readback. A new plan's skip never proves registry visibility.
|
|
const { supersededBy } = await verifyPublishedNpmRegistry({
|
|
packageName,
|
|
version: releaseVersion,
|
|
publishTags: [releaseDistTag],
|
|
fetchImpl: options.fetchImpl,
|
|
});
|
|
evidence.push({
|
|
packageName,
|
|
verification: "published-registry",
|
|
supersededBy,
|
|
childRunId: runId,
|
|
planAttempt: planner.run_attempt,
|
|
planArtifactId: planMetadata.id,
|
|
});
|
|
return supersededBy === null
|
|
? undefined
|
|
: `${packageName}@${releaseVersion} superseded by ${supersededBy}; dist-tag ${releaseDistTag} stays.`;
|
|
}
|
|
const { value: receipt, metadata } = await readJobReceipt(
|
|
job,
|
|
`plugin-npm-qualification-${entry.extensionId}-${runId}-${job.run_attempt}`,
|
|
`${entry.extensionId}-npm-qualification.json`,
|
|
);
|
|
requireValue(
|
|
isRecord(receipt) &&
|
|
receipt.repository === repository &&
|
|
receipt.workflowPath === WORKFLOW &&
|
|
Number.isSafeInteger(receipt.runId) &&
|
|
receipt.runId > 0 &&
|
|
Number.isSafeInteger(receipt.runAttempt) &&
|
|
receipt.runAttempt > 0 &&
|
|
Number.isSafeInteger(receipt.artifactId) &&
|
|
receipt.artifactId > 0 &&
|
|
receipt.workflowEvent === "workflow_dispatch" &&
|
|
receipt.workflowSha === workflowSha &&
|
|
receipt.targetSha === sourceSha &&
|
|
receipt.packageName === packageName &&
|
|
receipt.packageDir === entry.packageDir &&
|
|
receipt.version === entry.version &&
|
|
["npm-oidc", "npm-token-bootstrap", "npm-readback"].includes(receipt.route),
|
|
"Consumed plugin npm qualification differs from the approved package or source.",
|
|
);
|
|
const retained = receipt.runId === runId;
|
|
requireValue(
|
|
retained
|
|
? receipt.runStatePolicy === "same-run-producer-success" &&
|
|
receipt.consumerRunAttempt === job.run_attempt &&
|
|
receipt.runAttempt <= job.run_attempt &&
|
|
receipt.producerJobName === `Preflight plugin npm package (${packageName})`
|
|
: receipt.runStatePolicy === "completed-success",
|
|
"Consumed plugin npm qualification lost its producer-attempt binding.",
|
|
);
|
|
const qualifiedBinding = {
|
|
repository,
|
|
workflowPath: WORKFLOW,
|
|
workflowEvent: "workflow_dispatch",
|
|
workflowSha,
|
|
workflowHeadBranch: receipt.workflowHeadBranch,
|
|
runId: receipt.runId,
|
|
runAttempt: receipt.runAttempt,
|
|
artifactId: receipt.artifactId,
|
|
artifactName: receipt.artifactName,
|
|
artifactDigest: receipt.artifactDigest,
|
|
artifactSizeBytes: receipt.artifactSizeBytes,
|
|
// A current qualification was checked while its publisher was active;
|
|
// the completed child now supplies the stronger terminal-success proof.
|
|
runStatePolicy:
|
|
retained && receipt.runAttempt < run.run_attempt
|
|
? "same-run-producer-success"
|
|
: "completed-success",
|
|
consumerRunAttempt: run.run_attempt,
|
|
producerJobName: receipt.producerJobName,
|
|
};
|
|
const qualifiedRun = attempt(receipt.runId, receipt.runAttempt);
|
|
const qualifiedMetadata = api(`actions/artifacts/${receipt.artifactId}`);
|
|
const qualified = await download(qualifiedBinding, qualifiedMetadata, qualifiedRun);
|
|
const directory = mkdtempSync(join(cacheDir, "qualified-"));
|
|
const metadataPath = join(directory, "artifact.json");
|
|
const runPath = join(directory, "run.json");
|
|
const jobsPath = join(directory, "jobs.json");
|
|
writeFileSync(metadataPath, JSON.stringify(qualifiedMetadata));
|
|
writeFileSync(runPath, JSON.stringify(qualifiedRun));
|
|
if (qualified.workflowJobs) {
|
|
writeFileSync(jobsPath, JSON.stringify(qualified.workflowJobs));
|
|
}
|
|
const consumed = verifyPluginPublicationArtifact({
|
|
...qualifiedBinding,
|
|
packageDir: entry.packageDir,
|
|
packageName,
|
|
version: entry.version,
|
|
targetSha: sourceSha,
|
|
route: receipt.route,
|
|
publishTag: receipt.publishTag,
|
|
publicationReason: receipt.publicationReason,
|
|
publisherPolicy: {
|
|
schema: "openclaw.plugin-npm-publisher-policy/v1",
|
|
policyId: "plugin-npm-release-workflow",
|
|
sha256: createHash("sha256")
|
|
.update(
|
|
readFileSync(new URL("../.github/workflows/plugin-npm-release.yml", import.meta.url)),
|
|
)
|
|
.digest("hex"),
|
|
},
|
|
sourcePackageJsonSha256: createHash("sha256")
|
|
.update(
|
|
readBoundedRegularFile(join(sourceRoot, entry.packageDir, "package.json"), {
|
|
label: "frozen source package.json",
|
|
maxBytes: 1024 * 1024,
|
|
}),
|
|
)
|
|
.digest("hex"),
|
|
artifactMetadataPath: metadataPath,
|
|
workflowRunMetadataPath: runPath,
|
|
workflowJobsMetadataPath: jobsPath,
|
|
artifactZipPath: qualified.archivePath,
|
|
outputDir: join(directory, "package"),
|
|
});
|
|
await verifyPreparedNpmRegistry({
|
|
packageName,
|
|
version: entry.version,
|
|
route: receipt.route,
|
|
publishTags: [...new Set([receipt.publishTag, releaseDistTag])],
|
|
tarballPath: consumed.tarballPath,
|
|
allowMissing: false,
|
|
fetchImpl: options.fetchImpl,
|
|
});
|
|
evidence.push({
|
|
packageName,
|
|
verification: "qualified-artifact",
|
|
sourceSha,
|
|
workflowSha,
|
|
childRunId: runId,
|
|
childRunAttempt: run.run_attempt,
|
|
publisherAttempt: job.run_attempt,
|
|
planAttempt: planner.run_attempt,
|
|
planArtifactId: planMetadata.id,
|
|
receiptArtifactId: metadata.id,
|
|
receiptArtifactDigest: metadata.digest,
|
|
producerRunId: receipt.runId,
|
|
producerRunAttempt: receipt.runAttempt,
|
|
artifactId: receipt.artifactId,
|
|
artifactDigest: receipt.artifactDigest,
|
|
tarballSha256: consumed.tarballSha256,
|
|
});
|
|
return undefined;
|
|
},
|
|
};
|
|
}
|