mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
* fix(release): bound publish preflight observation, narrate frv continue, and surface SDK acknowledgement early * fix(release): replay failed preflight reads with their exact filter * test(release): model the exact-tag release lookup in preflight inventory fixtures
341 lines
12 KiB
JavaScript
341 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
|
|
const PLUGIN_SDK_API_RELEASE_EVIDENCE_SCHEMA = "openclaw.plugin-sdk-api-release-evidence/v1";
|
|
const PLUGIN_SDK_API_RELEASE_EVIDENCE_SET_SCHEMA =
|
|
"openclaw.plugin-sdk-api-release-evidence-set/v1";
|
|
|
|
const PLUGIN_SDK_API_RELEASE_POOLED_EVIDENCE_SET_SCHEMA =
|
|
"openclaw.plugin-sdk-api-release-evidence-set/v2";
|
|
const PLUGIN_SDK_API_DIFF_SET_SCHEMA = "openclaw.plugin-sdk-api-diff-set/v1";
|
|
|
|
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
|
|
const DIGEST_PATTERN = /^[a-f0-9]{64}$/u;
|
|
|
|
// Release workflows fetch this file directly from their trusted workflow SHA,
|
|
// so it must stay runnable without a workspace install.
|
|
function isReleaseEvidenceObject(value) {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function assertSha(value, label) {
|
|
if (typeof value !== "string" || !SHA_PATTERN.test(value)) {
|
|
throw new Error(`${label} must be a full lowercase commit SHA`);
|
|
}
|
|
}
|
|
|
|
function diffPayload(diff) {
|
|
if (
|
|
!isReleaseEvidenceObject(diff) ||
|
|
!Array.isArray(diff.entrypointsAdded) ||
|
|
!Array.isArray(diff.entrypointsRemoved) ||
|
|
!Array.isArray(diff.exports) ||
|
|
typeof diff.digest !== "string" ||
|
|
!DIGEST_PATTERN.test(diff.digest)
|
|
) {
|
|
throw new Error("Plugin SDK API release evidence contains an invalid diff");
|
|
}
|
|
return {
|
|
entrypointsAdded: diff.entrypointsAdded,
|
|
entrypointsRemoved: diff.entrypointsRemoved,
|
|
exports: diff.exports,
|
|
};
|
|
}
|
|
|
|
function hasChanges(payload) {
|
|
return (
|
|
payload.entrypointsAdded.length > 0 ||
|
|
payload.entrypointsRemoved.length > 0 ||
|
|
payload.exports.length > 0
|
|
);
|
|
}
|
|
|
|
export function createPluginSdkApiReleaseEvidence({
|
|
baseRef,
|
|
baseSha,
|
|
diff,
|
|
headSha,
|
|
workflowSha,
|
|
}) {
|
|
assertSha(baseSha, "Plugin SDK API evidence base SHA");
|
|
assertSha(headSha, "Plugin SDK API evidence head SHA");
|
|
assertSha(workflowSha, "Plugin SDK API evidence workflow SHA");
|
|
if (typeof baseRef !== "string" || baseRef.length === 0) {
|
|
throw new Error("Plugin SDK API evidence base ref is required");
|
|
}
|
|
const payload = diffPayload(diff);
|
|
const digest = createHash("sha256").update(JSON.stringify(payload), "utf8").digest("hex");
|
|
if (diff.digest !== digest) {
|
|
throw new Error("Plugin SDK API diff digest does not match its payload");
|
|
}
|
|
return {
|
|
schema: PLUGIN_SDK_API_RELEASE_EVIDENCE_SCHEMA,
|
|
status: "checked",
|
|
baseRef,
|
|
baseSha,
|
|
headSha,
|
|
hasChanges: hasChanges(payload),
|
|
digest,
|
|
diff,
|
|
workflowSha,
|
|
};
|
|
}
|
|
|
|
function requireSelectors(selectors) {
|
|
if (
|
|
!isReleaseEvidenceObject(selectors) ||
|
|
Object.keys(selectors).length !== 2 ||
|
|
!Object.hasOwn(selectors, "beta") ||
|
|
!Object.hasOwn(selectors, "latest")
|
|
) {
|
|
throw new Error("Plugin SDK API selector evidence must bind beta and latest");
|
|
}
|
|
}
|
|
|
|
// The renderer already reuses comparisons by predecessor commit. Preserve that
|
|
// sharing on disk without changing the v1 logical diff or acknowledgement digest.
|
|
export function createPluginSdkApiDiffSet(selectors) {
|
|
requireSelectors(selectors);
|
|
/** @type {Record<string, ReturnType<typeof diffPayload> & { digest: string }>} */
|
|
const diffs = {};
|
|
/** @type {Record<string, string>} */
|
|
const references = {};
|
|
for (const [selector, diff] of Object.entries(selectors)) {
|
|
const payload = diffPayload(diff);
|
|
const digest = createHash("sha256").update(JSON.stringify(payload), "utf8").digest("hex");
|
|
if (diff.digest !== digest) {
|
|
throw new Error("Plugin SDK API diff digest does not match its payload");
|
|
}
|
|
if (Object.hasOwn(diffs, digest) && JSON.stringify(diffs[digest]) !== JSON.stringify(diff)) {
|
|
throw new Error("Plugin SDK API pooled diffs disagree for the same digest");
|
|
}
|
|
diffs[digest] = diff;
|
|
references[selector] = digest;
|
|
}
|
|
return { schema: PLUGIN_SDK_API_DIFF_SET_SCHEMA, selectors: references, diffs };
|
|
}
|
|
|
|
export function expandPluginSdkApiDiffSet(value) {
|
|
if (
|
|
!isReleaseEvidenceObject(value) ||
|
|
value.schema !== PLUGIN_SDK_API_DIFF_SET_SCHEMA ||
|
|
Object.keys(value).length !== 3 ||
|
|
!isReleaseEvidenceObject(value.diffs)
|
|
) {
|
|
throw new Error("Plugin SDK API pooled diff set is invalid");
|
|
}
|
|
requireSelectors(value.selectors);
|
|
const references = Object.values(value.selectors);
|
|
if (
|
|
references.some((ref) => typeof ref !== "string" || !DIGEST_PATTERN.test(ref)) ||
|
|
Object.keys(value.diffs).length !== new Set(references).size ||
|
|
references.some((ref) => !Object.hasOwn(value.diffs, ref) || value.diffs[ref]?.digest !== ref)
|
|
) {
|
|
throw new Error("Plugin SDK API pooled diff references are invalid");
|
|
}
|
|
const selectors = Object.fromEntries(
|
|
Object.entries(value.selectors).map(([selector, ref]) => [selector, value.diffs[ref]]),
|
|
);
|
|
// Hash the complete payload, including the unselected channel; a pool reference
|
|
// never replaces payload verification or the selected channel's acknowledgement.
|
|
createPluginSdkApiDiffSet(selectors);
|
|
return selectors;
|
|
}
|
|
|
|
export function selectPluginSdkApiReleaseEvidence({ evidence, npmDistTag }) {
|
|
const pooled = evidence?.schema === PLUGIN_SDK_API_RELEASE_POOLED_EVIDENCE_SET_SCHEMA;
|
|
if (!pooled && evidence?.schema !== PLUGIN_SDK_API_RELEASE_EVIDENCE_SET_SCHEMA) {
|
|
return evidence;
|
|
}
|
|
const selectors = evidence.selectors;
|
|
if (
|
|
Object.keys(evidence).length !== (pooled ? 3 : 2) ||
|
|
!isReleaseEvidenceObject(selectors) ||
|
|
Object.keys(selectors).length !== 2 ||
|
|
["beta", "latest"].some(
|
|
(selector) => selectors[selector]?.schema !== PLUGIN_SDK_API_RELEASE_EVIDENCE_SCHEMA,
|
|
) ||
|
|
selectors.beta.headSha !== selectors.latest.headSha ||
|
|
selectors.beta.workflowSha !== selectors.latest.workflowSha
|
|
) {
|
|
throw new Error(
|
|
"Plugin SDK API selector evidence must bind beta and latest to one release and tooling SHA",
|
|
);
|
|
}
|
|
if (npmDistTag !== "beta" && npmDistTag !== "latest") {
|
|
throw new Error("Plugin SDK API selector evidence requires the beta or latest npm dist-tag");
|
|
}
|
|
if (!pooled) {
|
|
return selectors[npmDistTag];
|
|
}
|
|
const diffs = expandPluginSdkApiDiffSet({
|
|
schema: PLUGIN_SDK_API_DIFF_SET_SCHEMA,
|
|
selectors: Object.fromEntries(
|
|
Object.entries(selectors).map(([selector, receipt]) => [selector, receipt.diff]),
|
|
),
|
|
diffs: evidence.diffs,
|
|
});
|
|
return { ...selectors[npmDistTag], diff: diffs[npmDistTag] };
|
|
}
|
|
|
|
export function createPluginSdkApiReleaseEvidenceSet(selectors) {
|
|
// Check the existing identity contract before compacting either channel.
|
|
selectPluginSdkApiReleaseEvidence({
|
|
evidence: { schema: PLUGIN_SDK_API_RELEASE_EVIDENCE_SET_SCHEMA, selectors },
|
|
npmDistTag: "beta",
|
|
});
|
|
const pool = createPluginSdkApiDiffSet(
|
|
Object.fromEntries(
|
|
Object.entries(selectors).map(([selector, receipt]) => [selector, receipt.diff]),
|
|
),
|
|
);
|
|
return {
|
|
schema: PLUGIN_SDK_API_RELEASE_POOLED_EVIDENCE_SET_SCHEMA,
|
|
selectors: Object.fromEntries(
|
|
Object.entries(selectors).map(([selector, receipt]) => [
|
|
selector,
|
|
{ ...receipt, diff: pool.selectors[selector] },
|
|
]),
|
|
),
|
|
diffs: pool.diffs,
|
|
};
|
|
}
|
|
|
|
export class PluginSdkApiAcknowledgementError extends Error {
|
|
constructor(digest) {
|
|
super(`Plugin SDK API changes require acknowledgement digest ${digest}`);
|
|
this.digest = digest;
|
|
}
|
|
}
|
|
|
|
export function validatePluginSdkApiReleaseEvidence({
|
|
acknowledgement,
|
|
currentSelectorRef = "",
|
|
currentSelectorSha = "",
|
|
evidence: inputEvidence,
|
|
expectedHeadSha,
|
|
expectedWorkflowSha,
|
|
npmDistTag = "",
|
|
targetRef = "",
|
|
}) {
|
|
const evidence = selectPluginSdkApiReleaseEvidence({ evidence: inputEvidence, npmDistTag });
|
|
assertSha(expectedHeadSha, "Expected Plugin SDK API evidence head SHA");
|
|
if (
|
|
!isReleaseEvidenceObject(evidence) ||
|
|
evidence.schema !== PLUGIN_SDK_API_RELEASE_EVIDENCE_SCHEMA
|
|
) {
|
|
throw new Error("Plugin SDK API release evidence is missing or invalid");
|
|
}
|
|
assertSha(evidence.workflowSha, "Plugin SDK API evidence workflow SHA");
|
|
assertSha(expectedWorkflowSha, "Expected Plugin SDK API evidence workflow SHA");
|
|
if (evidence.workflowSha !== expectedWorkflowSha) {
|
|
throw new Error("Plugin SDK API evidence workflow SHA does not match trusted tooling");
|
|
}
|
|
if (evidence.status !== "checked") {
|
|
throw new Error("Plugin SDK API release evidence has an invalid status");
|
|
}
|
|
assertSha(evidence.baseSha, "Plugin SDK API evidence base SHA");
|
|
assertSha(evidence.headSha, "Plugin SDK API evidence head SHA");
|
|
if (evidence.headSha !== expectedHeadSha) {
|
|
throw new Error("Plugin SDK API evidence head SHA does not match the release");
|
|
}
|
|
if (typeof evidence.baseRef !== "string" || evidence.baseRef.length === 0) {
|
|
throw new Error("Plugin SDK API evidence base ref is missing");
|
|
}
|
|
const selectorValues = [currentSelectorRef, currentSelectorSha, targetRef];
|
|
const selectorValueCount = selectorValues.filter((value) => value !== "").length;
|
|
if (selectorValueCount !== 0 && selectorValueCount !== selectorValues.length) {
|
|
throw new Error("Current Plugin SDK API selector validation is incomplete");
|
|
}
|
|
if (selectorValueCount > 0) {
|
|
if (
|
|
typeof currentSelectorRef !== "string" ||
|
|
currentSelectorRef.length === 0 ||
|
|
typeof targetRef !== "string" ||
|
|
targetRef.length === 0
|
|
) {
|
|
throw new Error("Current Plugin SDK API selector refs are invalid");
|
|
}
|
|
assertSha(currentSelectorSha, "Current Plugin SDK API selector SHA");
|
|
if (currentSelectorRef === targetRef && currentSelectorSha !== expectedHeadSha) {
|
|
throw new Error("Current npm dist-tag target does not match the release SHA");
|
|
}
|
|
if (
|
|
currentSelectorRef !== targetRef &&
|
|
(evidence.baseRef !== currentSelectorRef || evidence.baseSha !== currentSelectorSha)
|
|
) {
|
|
throw new Error("Plugin SDK API evidence predecessor no longer matches the npm dist-tag");
|
|
}
|
|
}
|
|
const payload = diffPayload(evidence.diff);
|
|
const digest = createHash("sha256").update(JSON.stringify(payload), "utf8").digest("hex");
|
|
if (evidence.digest !== digest || evidence.diff.digest !== digest) {
|
|
throw new Error("Plugin SDK API release evidence digest does not match its diff");
|
|
}
|
|
const changed = hasChanges(payload);
|
|
if (evidence.hasChanges !== changed) {
|
|
throw new Error("Plugin SDK API release evidence change state does not match its diff");
|
|
}
|
|
const expectedAcknowledgement = digest.slice(0, 8);
|
|
if (changed && acknowledgement !== expectedAcknowledgement) {
|
|
throw new PluginSdkApiAcknowledgementError(expectedAcknowledgement);
|
|
}
|
|
return {
|
|
acknowledgement: changed ? expectedAcknowledgement : null,
|
|
digest,
|
|
hasChanges: changed,
|
|
status: "checked",
|
|
};
|
|
}
|
|
|
|
function readJson(filePath) {
|
|
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
}
|
|
|
|
function readArgs(argv) {
|
|
const values = new Map();
|
|
for (let index = 0; index < argv.length; index += 2) {
|
|
const flag = argv[index];
|
|
const value = argv[index + 1];
|
|
if (!flag?.startsWith("--") || value === undefined) {
|
|
throw new Error(
|
|
"Usage: plugin-sdk-api-release-evidence --manifest <path> --head <sha> --workflow-sha <sha> [--npm-dist-tag <tag>] [--acknowledge <digest>] [--current-selector-ref <tag> --current-selector-sha <sha> --target-ref <tag>]",
|
|
);
|
|
}
|
|
values.set(flag, value);
|
|
}
|
|
for (const flag of ["--manifest", "--head", "--workflow-sha"]) {
|
|
if (!values.has(flag)) {
|
|
throw new Error(`${flag} is required`);
|
|
}
|
|
}
|
|
return values;
|
|
}
|
|
|
|
function main() {
|
|
const args = readArgs(process.argv.slice(2));
|
|
const manifest = readJson(args.get("--manifest"));
|
|
const result = validatePluginSdkApiReleaseEvidence({
|
|
acknowledgement: args.get("--acknowledge") ?? "",
|
|
currentSelectorRef: args.get("--current-selector-ref"),
|
|
currentSelectorSha: args.get("--current-selector-sha"),
|
|
evidence: manifest.pluginSdkApi,
|
|
expectedHeadSha: args.get("--head"),
|
|
expectedWorkflowSha: args.get("--workflow-sha"),
|
|
npmDistTag: args.get("--npm-dist-tag"),
|
|
targetRef: args.get("--target-ref"),
|
|
});
|
|
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
}
|
|
|
|
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
|
|
try {
|
|
main();
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
process.exitCode = 1;
|
|
}
|
|
}
|