mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* fix(ci): bind release baselines to package candidates * fix(ci): preserve release candidate provenance * test(ci): complete candidate provenance fixtures * fix(release): preserve corrupt plugin allow membership * fix(release): keep corrupt plugin fixture off Codex * test(release): refresh candidate provenance fixtures * test(release): normalize package candidate rejection
707 lines
24 KiB
JavaScript
707 lines
24 KiB
JavaScript
#!/usr/bin/env node
|
|
import { createHash } from "node:crypto";
|
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
import { pathToFileURL } from "node:url";
|
|
import { canonicalAsciiJson, compareAscii } from "./lib/canonical-json.mjs";
|
|
import { isRecord } from "./lib/record-shared.mjs";
|
|
import {
|
|
normalizeUpgradeSurvivorBaselineSpec,
|
|
parseUpgradeSurvivorBaselineSpecs,
|
|
parseUpgradeSurvivorScenarios,
|
|
} from "./lib/upgrade-survivor-policy.mjs";
|
|
|
|
const FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA = "openclaw.full-release-candidate-request/v2";
|
|
const FULL_RELEASE_CANDIDATE_MANIFEST_SCHEMA = "openclaw.full-release-candidate/v2";
|
|
const FULL_RELEASE_CANDIDATE_BINDING_SCHEMA = "openclaw.full-release-candidate-binding/v2";
|
|
const FULL_RELEASE_CANDIDATE_ARTIFACT_PREFIX = "full-release-candidate-v2-";
|
|
|
|
const MANIFEST_MAX_BYTES = 32 * 1024;
|
|
const BINDING_MAX_BYTES = 40 * 1024;
|
|
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
|
|
const SHA256_PATTERN = /^[a-f0-9]{64}$/u;
|
|
const POSITIVE_DECIMAL_PATTERN = /^[1-9][0-9]*$/u;
|
|
const ISO_TIMESTAMP_PATTERN =
|
|
/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?Z$/u;
|
|
const ASCII_PATTERN = /^[\x20-\x7e]+$/u;
|
|
const PACKAGE_NAME_PATTERN = /^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/u;
|
|
const REPOSITORY_PATTERN = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u;
|
|
const WORKFLOW_PATH_PATTERN = /^\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml$/u;
|
|
const RELEASE_PROFILES = new Set(["minimum", "beta", "stable", "full"]);
|
|
const SHARED_IMAGE_POLICIES = new Set(["existing-only", "no-push-artifact"]);
|
|
function fail(message) {
|
|
throw new Error(message);
|
|
}
|
|
|
|
function exactKeys(value, keys, label) {
|
|
if (!isRecord(value)) {
|
|
fail(`${label} must be an object`);
|
|
}
|
|
const actual = Object.keys(value).toSorted(compareAscii);
|
|
const expected = [...keys].toSorted(compareAscii);
|
|
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
fail(`${label} keys must be exactly: ${expected.join(", ")}`);
|
|
}
|
|
}
|
|
|
|
function ascii(value, label) {
|
|
if (typeof value !== "string" || !ASCII_PATTERN.test(value)) {
|
|
fail(`${label} must be a non-empty printable ASCII string`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sha(value, label) {
|
|
const normalized = ascii(value, label);
|
|
if (!SHA_PATTERN.test(normalized)) {
|
|
fail(`${label} must be a lowercase 40-character commit SHA`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function sha256(value, label) {
|
|
if (typeof value !== "string" || !SHA256_PATTERN.test(value)) {
|
|
fail(`${label} must be 64 lowercase hexadecimal characters`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function repository(value, label) {
|
|
const normalized = ascii(value, label);
|
|
if (!REPOSITORY_PATTERN.test(normalized)) {
|
|
fail(`${label} must be an owner/repository slug`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function workflowPath(value, label) {
|
|
const normalized = ascii(value, label);
|
|
if (!WORKFLOW_PATH_PATTERN.test(normalized)) {
|
|
fail(`${label} must be a .github/workflows YAML path`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function positiveDecimal(value, label) {
|
|
const normalized = String(value ?? "");
|
|
if (!POSITIVE_DECIMAL_PATTERN.test(normalized)) {
|
|
fail(`${label} must be a positive decimal string`);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function boolean(value, label) {
|
|
if (typeof value !== "boolean") {
|
|
fail(`${label} must be boolean`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sortedUniquePackages(value, label) {
|
|
if (!Array.isArray(value)) {
|
|
fail(`${label} must be an array`);
|
|
}
|
|
const result = value.map((entry, index) => {
|
|
const packageName = ascii(entry, `${label}[${index}]`);
|
|
if (!PACKAGE_NAME_PATTERN.test(packageName)) {
|
|
fail(`${label}[${index}] must be a package name`);
|
|
}
|
|
return packageName;
|
|
});
|
|
if (
|
|
new Set(result).size !== result.length ||
|
|
result.some((entry, index) => index > 0 && compareAscii(result[index - 1], entry) >= 0)
|
|
) {
|
|
fail(`${label} must contain unique package names in ascending ASCII order`);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function stringArray(value, label) {
|
|
if (!Array.isArray(value)) {
|
|
fail(`${label} must be an array`);
|
|
}
|
|
return value.map((entry, index) => ascii(entry, `${label}[${index}]`));
|
|
}
|
|
|
|
export function buildFullReleaseCandidateRequest(input) {
|
|
if (!isRecord(input)) {
|
|
fail("full release candidate request input must be an object");
|
|
}
|
|
const explicitBaselines = parseUpgradeSurvivorBaselineSpecs(
|
|
typeof input.upgradeSurvivorBaselines === "string" ? input.upgradeSurvivorBaselines : undefined,
|
|
);
|
|
const defaultBaseline = normalizeUpgradeSurvivorBaselineSpec(
|
|
typeof input.upgradeSurvivorBaseline === "string" ? input.upgradeSurvivorBaseline : undefined,
|
|
);
|
|
const effectiveBaselines =
|
|
explicitBaselines.length > 0 ? explicitBaselines : defaultBaseline ? [defaultBaseline] : [];
|
|
const effectiveScenarios = parseUpgradeSurvivorScenarios(
|
|
typeof input.upgradeSurvivorScenarios === "string" ? input.upgradeSurvivorScenarios : undefined,
|
|
);
|
|
return validateFullReleaseCandidateRequest({
|
|
schema: FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA,
|
|
repository: input.repository,
|
|
targetSha: input.targetSha,
|
|
toolingSha: input.toolingSha,
|
|
releaseProfile: input.releaseProfile,
|
|
releaseSoak: input.releaseSoak,
|
|
upgradeSurvivorBaselines: effectiveBaselines.toSorted(compareAscii),
|
|
upgradeSurvivorScenarios: effectiveScenarios.toSorted(compareAscii),
|
|
allowFrozenTargetScenarioOmissions: input.allowFrozenTargetScenarioOmissions,
|
|
allowUnreleasedChangelog: input.allowUnreleasedChangelog,
|
|
packagePublished: input.packagePublished,
|
|
sharedImagePolicy: input.sharedImagePolicy,
|
|
contractVersions: {
|
|
package: 1,
|
|
prepublishPluginRegistry: 1,
|
|
sharedImage: 1,
|
|
},
|
|
});
|
|
}
|
|
|
|
export function validateFullReleaseCandidateRequest(value) {
|
|
exactKeys(
|
|
value,
|
|
[
|
|
"allowFrozenTargetScenarioOmissions",
|
|
"allowUnreleasedChangelog",
|
|
"contractVersions",
|
|
"packagePublished",
|
|
"releaseProfile",
|
|
"releaseSoak",
|
|
"repository",
|
|
"schema",
|
|
"sharedImagePolicy",
|
|
"targetSha",
|
|
"toolingSha",
|
|
"upgradeSurvivorBaselines",
|
|
"upgradeSurvivorScenarios",
|
|
],
|
|
"full release candidate request",
|
|
);
|
|
exactKeys(
|
|
value.contractVersions,
|
|
["package", "prepublishPluginRegistry", "sharedImage"],
|
|
"full release candidate request contractVersions",
|
|
);
|
|
if (
|
|
value.contractVersions.package !== 1 ||
|
|
value.contractVersions.prepublishPluginRegistry !== 1 ||
|
|
value.contractVersions.sharedImage !== 1
|
|
) {
|
|
fail("full release candidate request contract versions are invalid");
|
|
}
|
|
const releaseProfile = ascii(
|
|
value.releaseProfile,
|
|
"full release candidate request releaseProfile",
|
|
);
|
|
if (!RELEASE_PROFILES.has(releaseProfile)) {
|
|
fail("full release candidate request releaseProfile is invalid");
|
|
}
|
|
const sharedImagePolicy = ascii(
|
|
value.sharedImagePolicy,
|
|
"full release candidate request sharedImagePolicy",
|
|
);
|
|
if (!SHARED_IMAGE_POLICIES.has(sharedImagePolicy)) {
|
|
fail("full release candidate request sharedImagePolicy is invalid");
|
|
}
|
|
const baselines = stringArray(
|
|
value.upgradeSurvivorBaselines,
|
|
"full release candidate request upgradeSurvivorBaselines",
|
|
);
|
|
if (new Set(baselines).size !== baselines.length) {
|
|
fail("full release candidate request upgradeSurvivorBaselines must be unique");
|
|
}
|
|
if (
|
|
baselines.some((entry, index) => index > 0 && compareAscii(baselines[index - 1], entry) >= 0)
|
|
) {
|
|
fail(
|
|
"full release candidate request upgradeSurvivorBaselines must be in ascending ASCII order",
|
|
);
|
|
}
|
|
for (const baseline of baselines) {
|
|
if (normalizeUpgradeSurvivorBaselineSpec(baseline) !== baseline) {
|
|
fail("full release candidate request upgradeSurvivorBaselines are not normalized");
|
|
}
|
|
}
|
|
const scenarios = stringArray(
|
|
value.upgradeSurvivorScenarios,
|
|
"full release candidate request upgradeSurvivorScenarios",
|
|
);
|
|
if (
|
|
new Set(scenarios).size !== scenarios.length ||
|
|
scenarios.some((entry, index) => index > 0 && compareAscii(scenarios[index - 1], entry) >= 0) ||
|
|
JSON.stringify(parseUpgradeSurvivorScenarios(scenarios.join(" "))) !== JSON.stringify(scenarios)
|
|
) {
|
|
fail("full release candidate request upgradeSurvivorScenarios are not normalized");
|
|
}
|
|
if (value.schema !== FULL_RELEASE_CANDIDATE_REQUEST_SCHEMA) {
|
|
fail("full release candidate request schema is invalid");
|
|
}
|
|
return {
|
|
schema: value.schema,
|
|
repository: repository(value.repository, "full release candidate request repository"),
|
|
targetSha: sha(value.targetSha, "full release candidate request targetSha"),
|
|
toolingSha: sha(value.toolingSha, "full release candidate request toolingSha"),
|
|
releaseProfile,
|
|
releaseSoak: boolean(value.releaseSoak, "full release candidate request releaseSoak"),
|
|
packagePublished: boolean(
|
|
value.packagePublished,
|
|
"full release candidate request packagePublished",
|
|
),
|
|
upgradeSurvivorBaselines: baselines,
|
|
upgradeSurvivorScenarios: scenarios,
|
|
allowFrozenTargetScenarioOmissions: boolean(
|
|
value.allowFrozenTargetScenarioOmissions,
|
|
"full release candidate request allowFrozenTargetScenarioOmissions",
|
|
),
|
|
allowUnreleasedChangelog: boolean(
|
|
value.allowUnreleasedChangelog,
|
|
"full release candidate request allowUnreleasedChangelog",
|
|
),
|
|
sharedImagePolicy,
|
|
contractVersions: {
|
|
package: 1,
|
|
prepublishPluginRegistry: 1,
|
|
sharedImage: 1,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function canonicalFullReleaseCandidateRequestJson(value) {
|
|
return canonicalAsciiJson(validateFullReleaseCandidateRequest(value));
|
|
}
|
|
|
|
export function candidateRequestSha256(value) {
|
|
return createHash("sha256").update(canonicalFullReleaseCandidateRequestJson(value)).digest("hex");
|
|
}
|
|
|
|
export function fullReleaseCandidateArtifactName(requestSha256) {
|
|
return `${FULL_RELEASE_CANDIDATE_ARTIFACT_PREFIX}${sha256(
|
|
requestSha256,
|
|
"full release candidate requestSha256",
|
|
)}`;
|
|
}
|
|
|
|
function artifactIdentity(value, label) {
|
|
exactKeys(value, ["digest", "expiresAt", "id", "name", "runAttempt", "runId"], label);
|
|
const expiresAt = ascii(value.expiresAt, `${label} expiresAt`);
|
|
if (!ISO_TIMESTAMP_PATTERN.test(expiresAt) || Number.isNaN(Date.parse(expiresAt))) {
|
|
fail(`${label} expiresAt must be an ISO-8601 UTC timestamp`);
|
|
}
|
|
return {
|
|
digest: sha256(value.digest, `${label} digest`),
|
|
expiresAt,
|
|
id: positiveDecimal(value.id, `${label} id`),
|
|
name: ascii(value.name, `${label} name`),
|
|
runAttempt: positiveDecimal(value.runAttempt, `${label} runAttempt`),
|
|
runId: positiveDecimal(value.runId, `${label} runId`),
|
|
};
|
|
}
|
|
|
|
function validatePackage(value, request) {
|
|
exactKeys(
|
|
value,
|
|
["artifact", "fileName", "packageSha256", "sourceSha", "version"],
|
|
"full release candidate package",
|
|
);
|
|
const sourceSha = sha(value.sourceSha, "full release candidate package sourceSha");
|
|
if (sourceSha !== request.targetSha) {
|
|
fail("full release candidate package sourceSha does not match the request targetSha");
|
|
}
|
|
return {
|
|
artifact: artifactIdentity(value.artifact, "full release candidate package artifact"),
|
|
fileName: ascii(value.fileName, "full release candidate package fileName"),
|
|
packageSha256: sha256(value.packageSha256, "full release candidate package packageSha256"),
|
|
sourceSha,
|
|
version: ascii(value.version, "full release candidate package version"),
|
|
};
|
|
}
|
|
|
|
function validateRegistry(value, request, requiredPackages) {
|
|
exactKeys(
|
|
value,
|
|
["artifact", "manifestSha256", "sourceSha"],
|
|
"full release candidate prepublishPluginRegistry",
|
|
);
|
|
const sourceSha = sha(
|
|
value.sourceSha,
|
|
"full release candidate prepublishPluginRegistry sourceSha",
|
|
);
|
|
if (sourceSha !== request.targetSha || requiredPackages.length === 0) {
|
|
fail("full release candidate prerelease registry does not match the request");
|
|
}
|
|
return {
|
|
artifact: artifactIdentity(
|
|
value.artifact,
|
|
"full release candidate prepublishPluginRegistry artifact",
|
|
),
|
|
manifestSha256: sha256(
|
|
value.manifestSha256,
|
|
"full release candidate prepublishPluginRegistry manifestSha256",
|
|
),
|
|
sourceSha,
|
|
};
|
|
}
|
|
|
|
function validateSharedImage(value, packageSha256) {
|
|
exactKeys(
|
|
value,
|
|
["archiveSha256", "artifact", "packageSha256"],
|
|
"full release candidate sharedImage",
|
|
);
|
|
const imagePackageSha256 = sha256(
|
|
value.packageSha256,
|
|
"full release candidate sharedImage packageSha256",
|
|
);
|
|
if (imagePackageSha256 !== packageSha256) {
|
|
fail("full release candidate shared image packageSha256 does not match the package");
|
|
}
|
|
return {
|
|
archiveSha256: sha256(value.archiveSha256, "full release candidate sharedImage archiveSha256"),
|
|
artifact: artifactIdentity(value.artifact, "full release candidate sharedImage artifact"),
|
|
packageSha256: imagePackageSha256,
|
|
};
|
|
}
|
|
|
|
function assertProducedInSameAttempt(manifest) {
|
|
const expectedRunId = manifest.producer.runId;
|
|
const expectedRunAttempt = manifest.producer.runAttempt;
|
|
if (
|
|
manifest.publisher.runId !== expectedRunId ||
|
|
manifest.publisher.runAttempt !== expectedRunAttempt
|
|
) {
|
|
fail("full release candidate publisher was not bound to the declared producer attempt");
|
|
}
|
|
for (const [label, artifact] of [
|
|
["package", manifest.package.artifact],
|
|
["prepublish plugin registry", manifest.prepublishPluginRegistry.artifact],
|
|
["shared image", manifest.sharedImage.artifact],
|
|
]) {
|
|
if (artifact.runId !== expectedRunId || artifact.runAttempt !== expectedRunAttempt) {
|
|
fail(`full release candidate ${label} artifact was not produced by the declared attempt`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function validateCandidateJobIdentity(value, label, request) {
|
|
exactKeys(
|
|
value,
|
|
["jobId", "jobName", "repository", "runAttempt", "runId", "workflowPath", "workflowSha"],
|
|
label,
|
|
);
|
|
const identity = {
|
|
jobId: positiveDecimal(value.jobId, `${label} jobId`),
|
|
jobName: ascii(value.jobName, `${label} jobName`),
|
|
repository: repository(value.repository, `${label} repository`),
|
|
runAttempt: positiveDecimal(value.runAttempt, `${label} runAttempt`),
|
|
runId: positiveDecimal(value.runId, `${label} runId`),
|
|
workflowPath: workflowPath(value.workflowPath, `${label} workflowPath`),
|
|
workflowSha: sha(value.workflowSha, `${label} workflowSha`),
|
|
};
|
|
if (identity.repository !== request.repository || identity.workflowSha !== request.toolingSha) {
|
|
fail(`${label} does not match the request`);
|
|
}
|
|
return identity;
|
|
}
|
|
|
|
function validateFullReleaseCandidateManifest(value) {
|
|
exactKeys(
|
|
value,
|
|
[
|
|
"package",
|
|
"preparation",
|
|
"prepublishPluginRegistry",
|
|
"producer",
|
|
"publisher",
|
|
"request",
|
|
"requestSha256",
|
|
"schema",
|
|
"sharedImage",
|
|
],
|
|
"full release candidate manifest",
|
|
);
|
|
if (value.schema !== FULL_RELEASE_CANDIDATE_MANIFEST_SCHEMA) {
|
|
fail("full release candidate manifest schema is invalid");
|
|
}
|
|
const request = validateFullReleaseCandidateRequest(value.request);
|
|
const requestSha256 = sha256(value.requestSha256, "full release candidate requestSha256");
|
|
if (requestSha256 !== candidateRequestSha256(request)) {
|
|
fail("full release candidate requestSha256 does not match the request");
|
|
}
|
|
const producer = validateCandidateJobIdentity(
|
|
value.producer,
|
|
"full release candidate producer",
|
|
request,
|
|
);
|
|
const publisher = validateCandidateJobIdentity(
|
|
value.publisher,
|
|
"full release candidate publisher",
|
|
request,
|
|
);
|
|
exactKeys(
|
|
value.preparation,
|
|
["planSha256", "requiredPrepublishPluginPackages"],
|
|
"full release candidate preparation",
|
|
);
|
|
const preparation = {
|
|
planSha256: sha256(
|
|
value.preparation.planSha256,
|
|
"full release candidate preparation planSha256",
|
|
),
|
|
requiredPrepublishPluginPackages: sortedUniquePackages(
|
|
value.preparation.requiredPrepublishPluginPackages,
|
|
"full release candidate preparation requiredPrepublishPluginPackages",
|
|
),
|
|
};
|
|
const packageValue = validatePackage(value.package, request);
|
|
const manifest = {
|
|
schema: value.schema,
|
|
request,
|
|
requestSha256,
|
|
producer,
|
|
publisher,
|
|
preparation,
|
|
package: packageValue,
|
|
prepublishPluginRegistry: validateRegistry(
|
|
value.prepublishPluginRegistry,
|
|
request,
|
|
preparation.requiredPrepublishPluginPackages,
|
|
),
|
|
sharedImage: validateSharedImage(value.sharedImage, packageValue.packageSha256),
|
|
};
|
|
assertProducedInSameAttempt(manifest);
|
|
return manifest;
|
|
}
|
|
|
|
function buildFullReleaseCandidateManifest(input) {
|
|
if (!isRecord(input)) {
|
|
fail("full release candidate manifest input must be an object");
|
|
}
|
|
return validateFullReleaseCandidateManifest({
|
|
schema: FULL_RELEASE_CANDIDATE_MANIFEST_SCHEMA,
|
|
...input,
|
|
});
|
|
}
|
|
|
|
function canonicalFullReleaseCandidateManifestJson(value) {
|
|
const json = canonicalAsciiJson(validateFullReleaseCandidateManifest(value));
|
|
if (Buffer.byteLength(json) > MANIFEST_MAX_BYTES) {
|
|
fail(`full release candidate manifest exceeds ${MANIFEST_MAX_BYTES} bytes`);
|
|
}
|
|
return json;
|
|
}
|
|
|
|
function fullReleaseCandidateManifestSha256(value) {
|
|
return createHash("sha256")
|
|
.update(canonicalFullReleaseCandidateManifestJson(value))
|
|
.digest("hex");
|
|
}
|
|
|
|
export function buildFullReleaseCandidateBinding({ artifact, manifest }) {
|
|
const validatedManifest = validateFullReleaseCandidateManifest(manifest);
|
|
const evidenceArtifact = artifactIdentity(artifact, "full release candidate evidence artifact");
|
|
const expectedName = fullReleaseCandidateArtifactName(validatedManifest.requestSha256);
|
|
if (
|
|
evidenceArtifact.name !== expectedName ||
|
|
evidenceArtifact.runId !== validatedManifest.publisher.runId ||
|
|
evidenceArtifact.runAttempt !== validatedManifest.publisher.runAttempt
|
|
) {
|
|
fail("full release candidate evidence artifact does not match its manifest");
|
|
}
|
|
return validateFullReleaseCandidateBinding({
|
|
schema: FULL_RELEASE_CANDIDATE_BINDING_SCHEMA,
|
|
request: validatedManifest.request,
|
|
requestSha256: validatedManifest.requestSha256,
|
|
producer: validatedManifest.producer,
|
|
publisher: validatedManifest.publisher,
|
|
evidenceArtifact,
|
|
manifestSha256: fullReleaseCandidateManifestSha256(validatedManifest),
|
|
preparation: validatedManifest.preparation,
|
|
package: validatedManifest.package,
|
|
prepublishPluginRegistry: validatedManifest.prepublishPluginRegistry,
|
|
sharedImage: validatedManifest.sharedImage,
|
|
});
|
|
}
|
|
|
|
export function validateFullReleaseCandidateBinding(value) {
|
|
exactKeys(
|
|
value,
|
|
[
|
|
"evidenceArtifact",
|
|
"manifestSha256",
|
|
"package",
|
|
"preparation",
|
|
"prepublishPluginRegistry",
|
|
"producer",
|
|
"publisher",
|
|
"request",
|
|
"requestSha256",
|
|
"schema",
|
|
"sharedImage",
|
|
],
|
|
"full release candidate binding",
|
|
);
|
|
if (value.schema !== FULL_RELEASE_CANDIDATE_BINDING_SCHEMA) {
|
|
fail("full release candidate binding schema is invalid");
|
|
}
|
|
const request = validateFullReleaseCandidateRequest(value.request);
|
|
const requestSha256 = sha256(value.requestSha256, "full release candidate binding requestSha256");
|
|
if (requestSha256 !== candidateRequestSha256(request)) {
|
|
fail("full release candidate binding requestSha256 does not match the request");
|
|
}
|
|
const producer = validateCandidateJobIdentity(
|
|
value.producer,
|
|
"full release candidate binding producer",
|
|
request,
|
|
);
|
|
const publisher = validateCandidateJobIdentity(
|
|
value.publisher,
|
|
"full release candidate binding publisher",
|
|
request,
|
|
);
|
|
const evidenceArtifact = artifactIdentity(
|
|
value.evidenceArtifact,
|
|
"full release candidate binding evidenceArtifact",
|
|
);
|
|
if (
|
|
evidenceArtifact.name !== fullReleaseCandidateArtifactName(requestSha256) ||
|
|
evidenceArtifact.runId !== publisher.runId ||
|
|
evidenceArtifact.runAttempt !== publisher.runAttempt
|
|
) {
|
|
fail("full release candidate binding evidence artifact is invalid");
|
|
}
|
|
exactKeys(
|
|
value.preparation,
|
|
["planSha256", "requiredPrepublishPluginPackages"],
|
|
"full release candidate binding preparation",
|
|
);
|
|
const preparation = {
|
|
planSha256: sha256(
|
|
value.preparation.planSha256,
|
|
"full release candidate binding preparation planSha256",
|
|
),
|
|
requiredPrepublishPluginPackages: sortedUniquePackages(
|
|
value.preparation.requiredPrepublishPluginPackages,
|
|
"full release candidate binding preparation requiredPrepublishPluginPackages",
|
|
),
|
|
};
|
|
const packageValue = validatePackage(value.package, request);
|
|
const prepublishPluginRegistry = validateRegistry(
|
|
value.prepublishPluginRegistry,
|
|
request,
|
|
preparation.requiredPrepublishPluginPackages,
|
|
);
|
|
const sharedImage = validateSharedImage(value.sharedImage, packageValue.packageSha256);
|
|
const manifestSha256 = sha256(
|
|
value.manifestSha256,
|
|
"full release candidate binding manifestSha256",
|
|
);
|
|
const reconstructedManifest = {
|
|
schema: FULL_RELEASE_CANDIDATE_MANIFEST_SCHEMA,
|
|
request,
|
|
requestSha256,
|
|
producer,
|
|
publisher,
|
|
preparation,
|
|
package: packageValue,
|
|
prepublishPluginRegistry,
|
|
sharedImage,
|
|
};
|
|
if (manifestSha256 !== fullReleaseCandidateManifestSha256(reconstructedManifest)) {
|
|
fail("full release candidate binding manifestSha256 does not match its manifest fields");
|
|
}
|
|
const binding = {
|
|
schema: value.schema,
|
|
request,
|
|
requestSha256,
|
|
producer,
|
|
publisher,
|
|
evidenceArtifact,
|
|
manifestSha256,
|
|
preparation,
|
|
package: packageValue,
|
|
prepublishPluginRegistry,
|
|
sharedImage,
|
|
};
|
|
assertProducedInSameAttempt(binding);
|
|
if (Buffer.byteLength(canonicalAsciiJson(binding)) > BINDING_MAX_BYTES) {
|
|
fail(`full release candidate binding exceeds ${BINDING_MAX_BYTES} bytes`);
|
|
}
|
|
return binding;
|
|
}
|
|
|
|
function option(args, name) {
|
|
const index = args.indexOf(name);
|
|
if (index < 0 || !args[index + 1]) {
|
|
fail(`missing ${name}`);
|
|
}
|
|
return args[index + 1];
|
|
}
|
|
|
|
function readJson(path, label) {
|
|
let value;
|
|
try {
|
|
value = JSON.parse(readFileSync(path, "utf8"));
|
|
} catch (error) {
|
|
fail(`${label} is invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function runCli() {
|
|
const [command, ...args] = process.argv.slice(2);
|
|
if (command === "request") {
|
|
const request = buildFullReleaseCandidateRequest(
|
|
readJson(option(args, "--input"), "request input"),
|
|
);
|
|
const requestJson = canonicalFullReleaseCandidateRequestJson(request);
|
|
writeFileSync(option(args, "--output"), requestJson);
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
requestJson: requestJson.slice(0, -1),
|
|
requestSha256: candidateRequestSha256(request),
|
|
})}\n`,
|
|
);
|
|
return;
|
|
}
|
|
if (command === "manifest") {
|
|
const manifest = buildFullReleaseCandidateManifest(
|
|
readJson(option(args, "--input"), "manifest input"),
|
|
);
|
|
writeFileSync(option(args, "--output"), canonicalFullReleaseCandidateManifestJson(manifest));
|
|
process.stdout.write(
|
|
`${JSON.stringify({
|
|
manifestSha256: fullReleaseCandidateManifestSha256(manifest),
|
|
requestSha256: manifest.requestSha256,
|
|
})}\n`,
|
|
);
|
|
return;
|
|
}
|
|
if (command === "binding") {
|
|
const manifest = readJson(option(args, "--manifest"), "candidate manifest");
|
|
const binding = buildFullReleaseCandidateBinding({
|
|
manifest,
|
|
artifact: {
|
|
name: option(args, "--artifact-name"),
|
|
id: option(args, "--artifact-id"),
|
|
digest: option(args, "--artifact-digest"),
|
|
expiresAt: option(args, "--artifact-expires-at"),
|
|
runId: option(args, "--artifact-run-id"),
|
|
runAttempt: option(args, "--artifact-run-attempt"),
|
|
},
|
|
});
|
|
process.stdout.write(`${JSON.stringify(binding)}\n`);
|
|
return;
|
|
}
|
|
fail("usage: full-release-candidate-contract.mjs <request|manifest|binding> ...");
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
try {
|
|
runCli();
|
|
} catch (error) {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exit(1);
|
|
}
|
|
}
|