mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(release): harden immutable plan authority
This commit is contained in:
@@ -428,7 +428,7 @@ export function validateParentManifest(value, expected) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("release validation manifest must be an object");
|
||||
}
|
||||
if (![2, 3, 4].includes(value.version) || value.workflowName !== "Full Release Validation") {
|
||||
if (![2, 3].includes(value.version) || value.workflowName !== "Full Release Validation") {
|
||||
throw new Error("release validation manifest schema is unsupported");
|
||||
}
|
||||
if (String(value.runId) !== String(expected.runId)) {
|
||||
@@ -450,7 +450,7 @@ export function validateParentManifest(value, expected) {
|
||||
let workflowSha;
|
||||
let workflowFullRef;
|
||||
let workflowRefType;
|
||||
if (value.version >= 3) {
|
||||
if (value.version === 3) {
|
||||
workflowSha = normalizeSha(value.workflowSha, "release validation manifest workflow SHA");
|
||||
if (expected.workflowSha !== undefined && workflowSha !== expected.workflowSha) {
|
||||
throw new Error("release validation manifest workflow SHA mismatch");
|
||||
@@ -478,7 +478,7 @@ export function validateParentManifest(value, expected) {
|
||||
throw new Error("release validation manifest release soak value is invalid");
|
||||
}
|
||||
const controls = normalizeJsonObject(value.controls, "release validation manifest controls");
|
||||
if (value.version >= 3 && controls.performanceReportPublication !== "artifact-only") {
|
||||
if (value.version === 3 && controls.performanceReportPublication !== "artifact-only") {
|
||||
throw new Error("release validation manifest performance report publication mode is invalid");
|
||||
}
|
||||
const validationInputs =
|
||||
@@ -1210,8 +1210,8 @@ export function validateTrustedProducerIdentity(
|
||||
);
|
||||
}
|
||||
if (shaPinned) {
|
||||
if (manifest.version < 3) {
|
||||
throw new Error("SHA-pinned release evidence requires a v3 or v4 manifest");
|
||||
if (manifest.version !== 3) {
|
||||
throw new Error("SHA-pinned release evidence requires a v3 manifest");
|
||||
}
|
||||
if (!manifest.workflowRef.startsWith(`release-ci/${manifest.workflowSha.slice(0, 12)}-`)) {
|
||||
throw new Error("SHA-pinned release evidence branch does not match its workflow SHA");
|
||||
@@ -1231,16 +1231,15 @@ export function validateTrustedProducerIdentity(
|
||||
}
|
||||
|
||||
let workflowRefProof = "legacy-v2-main-ancestry";
|
||||
if (manifest.version >= 3) {
|
||||
if (manifest.version === 3) {
|
||||
if (manifest.workflowRefType !== "branch" || manifest.workflowFullRef !== expectedFullRef) {
|
||||
throw new Error("release evidence producer workflow full ref is not trusted");
|
||||
}
|
||||
const manifestName = `manifest-v${manifest.version}`;
|
||||
workflowRefProof = protectedTagRoute
|
||||
? `${manifestName}-protected-tag-exact-sha`
|
||||
? "manifest-v3-protected-tag-exact-sha"
|
||||
: shaPinned
|
||||
? `${manifestName}-sha-pinned-main-ancestry`
|
||||
: `${manifestName}-branch`;
|
||||
? "manifest-v3-sha-pinned-main-ancestry"
|
||||
: "manifest-v3-branch";
|
||||
}
|
||||
|
||||
if (!protectedTagRoute) {
|
||||
|
||||
@@ -22,9 +22,10 @@ export type ReleasePlan = {
|
||||
profile: "beta" | "stable" | "full";
|
||||
soak: boolean;
|
||||
allowed_groups: string[];
|
||||
exceptions: Array<{ code: string; reason: string }>;
|
||||
};
|
||||
inventory: {
|
||||
packages: Array<{ name: string; version: string; targets: string[] }>;
|
||||
packages: Array<{ name: string; version: string; targets: Array<"clawhub" | "npm"> }>;
|
||||
platforms: Array<{ id: string; source: string }>;
|
||||
};
|
||||
};
|
||||
@@ -35,15 +36,40 @@ export type ReleasePlanLock = {
|
||||
plan: ReleasePlan;
|
||||
};
|
||||
|
||||
export type ValidationAttemptRequest = {
|
||||
schema: "openclaw.validation-attempt-request.v1";
|
||||
plan_digest: string;
|
||||
rerun_group: string;
|
||||
filters: Record<string, string>;
|
||||
fail_fast: boolean;
|
||||
reuse_evidence: boolean;
|
||||
};
|
||||
|
||||
export type ValidationAttemptReceipt = {
|
||||
schema: "openclaw.validation-attempt-receipt.v1";
|
||||
plan_digest: string;
|
||||
request_digest: string;
|
||||
run_id: string;
|
||||
run_attempt: string;
|
||||
workflow_ref: string;
|
||||
workflow_full_ref: string;
|
||||
workflow_sha: string;
|
||||
target_sha: string;
|
||||
};
|
||||
|
||||
export const RELEASE_PLAN_SCHEMA: "openclaw.release-plan.v1";
|
||||
export const RELEASE_PLAN_LOCK_SCHEMA: "openclaw.release-plan-lock.v1";
|
||||
export const VALIDATION_ATTEMPT_REQUEST_SCHEMA: "openclaw.validation-attempt-request.v1";
|
||||
export const VALIDATION_ATTEMPT_RECEIPT_SCHEMA: "openclaw.validation-attempt-receipt.v1";
|
||||
export const RELEASE_PLAN_CANONICALIZATION: "ascii-sorted-compact-json-trailing-newline-v1";
|
||||
export const RELEASE_PLAN_MAX_BYTES: number;
|
||||
export const VALIDATION_ATTEMPT_REQUEST_MAX_BYTES: number;
|
||||
export function validateReleasePlan(value: unknown): ReleasePlan;
|
||||
export function canonicalReleasePlanJson(value: unknown): string;
|
||||
export function releasePlanDigest(value: unknown): string;
|
||||
export function createReleasePlanLock(value: unknown): ReleasePlanLock;
|
||||
export function validateReleasePlanDigest(value: unknown): string;
|
||||
export function validateReleasePlanLock(value: unknown): ReleasePlanLock;
|
||||
export function canonicalReleasePlanLockJson(value: unknown): string;
|
||||
export function parseReleasePlanLockJson(text: string): ReleasePlanLock;
|
||||
export function validateValidationAttemptRequest(value: unknown): ValidationAttemptRequest;
|
||||
export function validateValidationAttemptReceipt(value: unknown): ValidationAttemptReceipt;
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseDocument } from "yaml";
|
||||
import { isRecord } from "./lib/record-shared.mjs";
|
||||
import { parseReleaseVersion } from "./lib/release-version.mjs";
|
||||
|
||||
export const RELEASE_PLAN_SCHEMA = "openclaw.release-plan.v1";
|
||||
export const RELEASE_PLAN_LOCK_SCHEMA = "openclaw.release-plan-lock.v1";
|
||||
export const VALIDATION_ATTEMPT_REQUEST_SCHEMA = "openclaw.validation-attempt-request.v1";
|
||||
export const VALIDATION_ATTEMPT_RECEIPT_SCHEMA = "openclaw.validation-attempt-receipt.v1";
|
||||
export const RELEASE_PLAN_CANONICALIZATION = "ascii-sorted-compact-json-trailing-newline-v1";
|
||||
export const RELEASE_PLAN_MAX_BYTES = 32 * 1024;
|
||||
export const VALIDATION_ATTEMPT_REQUEST_MAX_BYTES = 8 * 1024;
|
||||
|
||||
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||
const POSITIVE_INTEGER_PATTERN = /^[1-9][0-9]*$/u;
|
||||
const ASCII_PATTERN = /^[\x20-\x7e]+$/u;
|
||||
const REPOSITORY = "openclaw/openclaw";
|
||||
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
|
||||
@@ -49,6 +54,21 @@ function sha(value, label) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function digest(value, label) {
|
||||
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) {
|
||||
fail(`${label} must be sha256:<64 lowercase hex characters>`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveIntegerString(value, label) {
|
||||
const normalized = asciiString(value, label);
|
||||
if (!POSITIVE_INTEGER_PATTERN.test(normalized)) {
|
||||
fail(`${label} must be a positive integer string`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sortedUniqueStrings(value, label) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
fail(`${label} must be a non-empty array`);
|
||||
@@ -56,7 +76,7 @@ function sortedUniqueStrings(value, label) {
|
||||
const result = value.map((entry, index) => asciiString(entry, `${label}[${index}]`));
|
||||
if (
|
||||
new Set(result).size !== result.length ||
|
||||
result.some((entry, index) => index > 0 && result[index - 1] >= entry)
|
||||
result.some((entry, index) => index > 0 && compareAscii(result[index - 1], entry) >= 0)
|
||||
) {
|
||||
fail(`${label} must contain unique strings in ascending ASCII order`);
|
||||
}
|
||||
@@ -116,7 +136,7 @@ function validatePackages(value) {
|
||||
const names = packages.map((entry) => entry.name);
|
||||
if (
|
||||
new Set(names).size !== names.length ||
|
||||
names.some((entry, index) => index > 0 && names[index - 1] >= entry)
|
||||
names.some((entry, index) => index > 0 && compareAscii(names[index - 1], entry) >= 0)
|
||||
) {
|
||||
fail("release plan packages must have unique names in ascending ASCII order");
|
||||
}
|
||||
@@ -140,13 +160,60 @@ function validatePlatforms(value) {
|
||||
const ids = platforms.map((entry) => entry.id);
|
||||
if (
|
||||
new Set(ids).size !== ids.length ||
|
||||
ids.some((entry, index) => index > 0 && ids[index - 1] >= entry)
|
||||
ids.some((entry, index) => index > 0 && compareAscii(ids[index - 1], entry) >= 0)
|
||||
) {
|
||||
fail("release plan platforms must have unique ids in ascending ASCII order");
|
||||
}
|
||||
return platforms;
|
||||
}
|
||||
|
||||
function validateExceptions(value) {
|
||||
if (!Array.isArray(value)) {
|
||||
fail("release plan validation exceptions must be an array");
|
||||
}
|
||||
const exceptions = value.map((entry, index) => {
|
||||
if (!isRecord(entry)) {
|
||||
fail(`release plan validation exceptions[${index}] must be an object`);
|
||||
}
|
||||
exactKeys(entry, ["code", "reason"], `release plan validation exceptions[${index}]`);
|
||||
return {
|
||||
code: asciiString(entry.code, `release plan validation exceptions[${index}].code`),
|
||||
reason: asciiString(entry.reason, `release plan validation exceptions[${index}].reason`),
|
||||
};
|
||||
});
|
||||
const codes = exceptions.map((entry) => entry.code);
|
||||
if (
|
||||
new Set(codes).size !== codes.length ||
|
||||
codes.some((entry, index) => index > 0 && compareAscii(codes[index - 1], entry) >= 0)
|
||||
) {
|
||||
fail("release plan validation exceptions must have unique codes in ascending ASCII order");
|
||||
}
|
||||
return exceptions;
|
||||
}
|
||||
|
||||
function validatePurposeMatrix({ candidateSha, purpose, tag, targetContextRef, version }) {
|
||||
const parsedVersion = parseReleaseVersion(version);
|
||||
if (parsedVersion === null || parsedVersion.version !== version) {
|
||||
fail("release plan version must use a supported release version");
|
||||
}
|
||||
if (purpose === "beta-publish" && parsedVersion.channel === "stable") {
|
||||
fail("beta-publish release plan version must be alpha or beta");
|
||||
}
|
||||
if (purpose === "stable-publish" && parsedVersion.channel !== "stable") {
|
||||
fail("stable-publish release plan version must be stable");
|
||||
}
|
||||
if (purpose === "main-qualification") {
|
||||
if (tag !== null || targetContextRef !== candidateSha) {
|
||||
fail("main-qualification release plans require a null tag and candidate SHA context");
|
||||
}
|
||||
return;
|
||||
}
|
||||
const expectedTag = `v${version}`;
|
||||
if (tag !== expectedTag || targetContextRef !== `refs/tags/${expectedTag}`) {
|
||||
fail(`${purpose} release plans require the exact version tag context`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateReleasePlan(value) {
|
||||
if (!isRecord(value)) {
|
||||
fail("release plan must be an object");
|
||||
@@ -180,22 +247,10 @@ export function validateReleasePlan(value) {
|
||||
fail("release plan release_id must equal version");
|
||||
}
|
||||
const tag = value.tag === null ? null : asciiString(value.tag, "release plan tag");
|
||||
if (
|
||||
(purpose === "beta-publish" ||
|
||||
purpose === "stable-publish" ||
|
||||
purpose === "postpublish-confidence") &&
|
||||
tag !== `v${version}`
|
||||
) {
|
||||
fail(`${purpose} release plan tag must equal v<version>`);
|
||||
}
|
||||
if (purpose === "main-qualification" && tag !== null) {
|
||||
fail("main-qualification release plans must not carry a tag");
|
||||
}
|
||||
const candidateSha = sha(value.candidate_sha, "release plan candidate SHA");
|
||||
const targetContextRef = asciiString(value.target_context_ref, "release plan target_context_ref");
|
||||
if (targetContextRef !== candidateSha && targetContextRef !== `refs/tags/${tag}`) {
|
||||
fail("release plan target_context_ref must bind the candidate SHA or release tag");
|
||||
}
|
||||
validatePurposeMatrix({ candidateSha, purpose, tag, targetContextRef, version });
|
||||
|
||||
const expectedPolicy = {
|
||||
"beta-publish": { profile: "beta", soak: false },
|
||||
"stable-publish": { profile: "stable", soak: true },
|
||||
@@ -207,13 +262,17 @@ export function validateReleasePlan(value) {
|
||||
}
|
||||
exactKeys(value.tooling, ["repository", "workflow_path", "ref", "sha"], "release plan tooling");
|
||||
const toolingRef = asciiString(value.tooling.ref, "release plan tooling ref");
|
||||
if (!/^refs\/(?:heads|tags)\/.+/u.test(toolingRef)) {
|
||||
if (!/^refs\/(?:heads|tags)\/[A-Za-z0-9._/-]+$/u.test(toolingRef)) {
|
||||
fail("release plan tooling ref must be a qualified branch or tag ref");
|
||||
}
|
||||
if (!isRecord(value.validation)) {
|
||||
fail("release plan validation must be an object");
|
||||
}
|
||||
exactKeys(value.validation, ["profile", "soak", "allowed_groups"], "release plan validation");
|
||||
exactKeys(
|
||||
value.validation,
|
||||
["profile", "soak", "allowed_groups", "exceptions"],
|
||||
"release plan validation",
|
||||
);
|
||||
const profile = asciiString(value.validation.profile, "release plan validation profile");
|
||||
if (!PROFILES.has(profile)) {
|
||||
fail(`unsupported release plan validation profile: ${profile}`);
|
||||
@@ -249,6 +308,7 @@ export function validateReleasePlan(value) {
|
||||
value.validation.allowed_groups,
|
||||
"release plan validation allowed_groups",
|
||||
),
|
||||
exceptions: validateExceptions(value.validation.exceptions),
|
||||
},
|
||||
inventory: {
|
||||
packages: validatePackages(value.inventory.packages),
|
||||
@@ -284,13 +344,6 @@ export function createReleasePlanLock(value) {
|
||||
};
|
||||
}
|
||||
|
||||
export function validateReleasePlanDigest(value) {
|
||||
if (typeof value !== "string" || !DIGEST_PATTERN.test(value)) {
|
||||
fail("release plan digest must be sha256:<64 lowercase hex characters>");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function validateReleasePlanLock(value) {
|
||||
if (!isRecord(value)) {
|
||||
fail("release plan lock must be an object");
|
||||
@@ -300,11 +353,11 @@ export function validateReleasePlanLock(value) {
|
||||
fail(`release plan lock schema must be ${RELEASE_PLAN_LOCK_SCHEMA}`);
|
||||
}
|
||||
const plan = validateReleasePlan(value.plan);
|
||||
const digest = validateReleasePlanDigest(value.digest);
|
||||
if (digest !== releasePlanDigest(plan)) {
|
||||
const planDigest = digest(value.digest, "release plan lock digest");
|
||||
if (planDigest !== releasePlanDigest(plan)) {
|
||||
fail("release plan lock digest does not match its canonical plan");
|
||||
}
|
||||
return { schema: RELEASE_PLAN_LOCK_SCHEMA, digest, plan };
|
||||
return { schema: RELEASE_PLAN_LOCK_SCHEMA, digest: planDigest, plan };
|
||||
}
|
||||
|
||||
export function canonicalReleasePlanLockJson(value) {
|
||||
@@ -315,12 +368,8 @@ export function parseReleasePlanLockJson(text) {
|
||||
if (typeof text !== "string" || Buffer.byteLength(text, "utf8") > RELEASE_PLAN_MAX_BYTES + 4096) {
|
||||
fail("release plan lock JSON is missing or too large");
|
||||
}
|
||||
const hasNonAscii = [...text].some((character) => {
|
||||
const code = character.charCodeAt(0);
|
||||
return code !== 9 && code !== 10 && code !== 13 && (code < 32 || code > 126);
|
||||
});
|
||||
if (hasNonAscii) {
|
||||
fail("release plan lock JSON must contain only ASCII");
|
||||
if (!/^[\x20-\x7e]+\n$/u.test(text)) {
|
||||
fail("release plan lock JSON must be compact printable ASCII with exactly one trailing LF");
|
||||
}
|
||||
const document = parseDocument(text, { strict: true, uniqueKeys: true });
|
||||
if (document.errors.length > 0) {
|
||||
@@ -333,5 +382,95 @@ export function parseReleasePlanLockJson(text) {
|
||||
: `release plan lock JSON is invalid: ${document.errors[0].message}`,
|
||||
);
|
||||
}
|
||||
return validateReleasePlanLock(JSON.parse(text));
|
||||
let value;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch (error) {
|
||||
throw new Error("release plan lock JSON is invalid JSON", { cause: error });
|
||||
}
|
||||
const lock = validateReleasePlanLock(value);
|
||||
if (text !== canonicalReleasePlanLockJson(lock)) {
|
||||
fail("release plan lock JSON does not use canonical bytes");
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
export function validateValidationAttemptRequest(value) {
|
||||
if (!isRecord(value)) {
|
||||
fail("validation attempt request must be an object");
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
["schema", "plan_digest", "rerun_group", "filters", "fail_fast", "reuse_evidence"],
|
||||
"validation attempt request",
|
||||
);
|
||||
if (value.schema !== VALIDATION_ATTEMPT_REQUEST_SCHEMA) {
|
||||
fail(`validation attempt request schema must be ${VALIDATION_ATTEMPT_REQUEST_SCHEMA}`);
|
||||
}
|
||||
if (!isRecord(value.filters)) {
|
||||
fail("validation attempt request filters must be an object");
|
||||
}
|
||||
const filters = Object.fromEntries(
|
||||
Object.entries(value.filters)
|
||||
.map(([key, entry]) => [
|
||||
asciiString(key, "validation attempt request filter key"),
|
||||
asciiString(entry, `validation attempt request filter ${key}`),
|
||||
])
|
||||
.toSorted(([left], [right]) => compareAscii(left, right)),
|
||||
);
|
||||
if (typeof value.fail_fast !== "boolean" || typeof value.reuse_evidence !== "boolean") {
|
||||
fail("validation attempt request fail_fast and reuse_evidence must be booleans");
|
||||
}
|
||||
const request = {
|
||||
schema: VALIDATION_ATTEMPT_REQUEST_SCHEMA,
|
||||
plan_digest: digest(value.plan_digest, "validation attempt request plan_digest"),
|
||||
rerun_group: asciiString(value.rerun_group, "validation attempt request rerun_group"),
|
||||
filters,
|
||||
fail_fast: value.fail_fast,
|
||||
reuse_evidence: value.reuse_evidence,
|
||||
};
|
||||
if (
|
||||
Buffer.byteLength(canonicalAsciiJson(request), "ascii") > VALIDATION_ATTEMPT_REQUEST_MAX_BYTES
|
||||
) {
|
||||
fail(`validation attempt request exceeds ${VALIDATION_ATTEMPT_REQUEST_MAX_BYTES} bytes`);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
export function validateValidationAttemptReceipt(value) {
|
||||
if (!isRecord(value)) {
|
||||
fail("validation attempt receipt must be an object");
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
"schema",
|
||||
"plan_digest",
|
||||
"request_digest",
|
||||
"run_id",
|
||||
"run_attempt",
|
||||
"workflow_ref",
|
||||
"workflow_full_ref",
|
||||
"workflow_sha",
|
||||
"target_sha",
|
||||
],
|
||||
"validation attempt receipt",
|
||||
);
|
||||
if (value.schema !== VALIDATION_ATTEMPT_RECEIPT_SCHEMA) {
|
||||
fail(`validation attempt receipt schema must be ${VALIDATION_ATTEMPT_RECEIPT_SCHEMA}`);
|
||||
}
|
||||
return {
|
||||
schema: VALIDATION_ATTEMPT_RECEIPT_SCHEMA,
|
||||
plan_digest: digest(value.plan_digest, "validation attempt receipt plan_digest"),
|
||||
request_digest: digest(value.request_digest, "validation attempt receipt request_digest"),
|
||||
run_id: positiveIntegerString(value.run_id, "validation attempt receipt run_id"),
|
||||
run_attempt: positiveIntegerString(value.run_attempt, "validation attempt receipt run_attempt"),
|
||||
workflow_ref: asciiString(value.workflow_ref, "validation attempt receipt workflow_ref"),
|
||||
workflow_full_ref: asciiString(
|
||||
value.workflow_full_ref,
|
||||
"validation attempt receipt workflow_full_ref",
|
||||
),
|
||||
workflow_sha: sha(value.workflow_sha, "validation attempt receipt workflow_sha"),
|
||||
target_sha: sha(value.target_sha, "validation attempt receipt target_sha"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"schema": "openclaw.release-plan-inventory.v1",
|
||||
"platforms": [
|
||||
{
|
||||
"id": "android",
|
||||
"source": ".github/workflows/android-release.yml"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": ".github/workflows/docker-release.yml"
|
||||
},
|
||||
{
|
||||
"id": "windows",
|
||||
"source": ".github/workflows/windows-node-release.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,28 +1,140 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import { collectClawHubPublishablePluginPackages } from "./lib/plugin-clawhub-release.ts";
|
||||
import { collectPublishablePluginPackages } from "./lib/plugin-npm-release.ts";
|
||||
import { parseReleaseVersion } from "./lib/release-version.mjs";
|
||||
import {
|
||||
canonicalReleasePlanJson,
|
||||
canonicalReleasePlanLockJson,
|
||||
createReleasePlanLock,
|
||||
parseReleasePlanLockJson,
|
||||
RELEASE_PLAN_SCHEMA,
|
||||
validateReleasePlan,
|
||||
type ReleasePlan,
|
||||
type ReleasePlanLock,
|
||||
type ReleasePlanPurpose,
|
||||
} from "./release-plan-contract.mjs";
|
||||
|
||||
type ReleasePlanIntent = "publish" | "postpublish-confidence" | "main-qualification";
|
||||
export type ReleasePlanIntent = "publish" | "postpublish-confidence" | "main-qualification";
|
||||
|
||||
type ReleasePlanSource = {
|
||||
repoRoot?: string;
|
||||
intent: ReleasePlanIntent;
|
||||
candidateSha: string;
|
||||
candidateRef: string;
|
||||
toolingSha: string;
|
||||
toolingFullRef: string;
|
||||
};
|
||||
|
||||
const REPOSITORY = "openclaw/openclaw";
|
||||
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
|
||||
const INVENTORY_PATH = "scripts/release-plan-inventory.json";
|
||||
const VALIDATION_WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
|
||||
const PUBLICATION_WORKFLOW_PATH = ".github/workflows/openclaw-release-publish.yml";
|
||||
const PRODUCER_PATH = "scripts/release-plan-producer.mts";
|
||||
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
|
||||
const compareAscii = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
|
||||
|
||||
function readJson(path: string): unknown {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
function git(repoRoot: string, args: string[]): string {
|
||||
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function resolveCommit(repoRoot: string, revision: string, label: string): string {
|
||||
let resolved: string;
|
||||
try {
|
||||
resolved = git(repoRoot, ["rev-parse", "--verify", `${revision}^{commit}`]);
|
||||
} catch {
|
||||
throw new Error(`${label} does not resolve to a commit: ${revision}`);
|
||||
}
|
||||
if (!SHA_PATTERN.test(resolved)) {
|
||||
throw new Error(`${label} did not resolve to an exact lowercase commit SHA`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function requireExactSha(value: string, label: string): string {
|
||||
if (!SHA_PATTERN.test(value)) {
|
||||
throw new Error(`${label} must be an exact lowercase 40-character commit SHA`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireQualifiedRef(value: string, label: string): string {
|
||||
if (!/^refs\/(?:heads|tags)\/[A-Za-z0-9._/-]+$/u.test(value)) {
|
||||
throw new Error(`${label} must be a qualified branch or tag ref`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function readGitText(repoRoot: string, commit: string, path: string): string {
|
||||
try {
|
||||
return execFileSync("git", ["show", `${commit}:${path}`], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
} catch {
|
||||
throw new Error(`${path} is missing from ${commit}`);
|
||||
}
|
||||
}
|
||||
|
||||
function gitPathExists(repoRoot: string, commit: string, path: string): boolean {
|
||||
try {
|
||||
execFileSync("git", ["cat-file", "-e", `${commit}:${path}`], {
|
||||
cwd: repoRoot,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function withCandidateSnapshot<T>(
|
||||
repoRoot: string,
|
||||
candidateSha: string,
|
||||
callback: (snapshotRoot: string) => T,
|
||||
): T {
|
||||
const snapshotRoot = mkdtempSync(join(tmpdir(), "openclaw-release-candidate-"));
|
||||
try {
|
||||
const tree = execFileSync(
|
||||
"git",
|
||||
["ls-tree", "-r", "-z", candidateSha, "--", "package.json", "extensions"],
|
||||
{ cwd: repoRoot },
|
||||
).toString("utf8");
|
||||
const inventoryPaths: string[] = [];
|
||||
for (const entry of tree.split("\0").filter(Boolean)) {
|
||||
const [metadata, path] = entry.split("\t");
|
||||
if (
|
||||
!path ||
|
||||
(path !== "package.json" &&
|
||||
!/^extensions\/[^/]+\/(?:package\.json|README\.md)$/u.test(path))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (metadata?.startsWith("120000 ")) {
|
||||
throw new Error("candidate package inventory must not contain symbolic links");
|
||||
}
|
||||
inventoryPaths.push(path);
|
||||
}
|
||||
if (!inventoryPaths.includes("package.json")) {
|
||||
throw new Error("candidate package.json is missing");
|
||||
}
|
||||
const archivePath = join(snapshotRoot, "candidate.tar");
|
||||
execFileSync(
|
||||
"git",
|
||||
["archive", "--format=tar", `--output=${archivePath}`, candidateSha, "--", ...inventoryPaths],
|
||||
{ cwd: repoRoot },
|
||||
);
|
||||
execFileSync("tar", ["-xf", archivePath, "-C", snapshotRoot]);
|
||||
mkdirSync(join(snapshotRoot, "extensions"), { recursive: true });
|
||||
return callback(snapshotRoot);
|
||||
} finally {
|
||||
rmSync(snapshotRoot, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function deriveReleasePlanPolicy(
|
||||
@@ -34,7 +146,10 @@ export function deriveReleasePlanPolicy(
|
||||
soak: boolean;
|
||||
tag: string | null;
|
||||
} {
|
||||
const prerelease = /-(?:alpha|beta)\.[1-9][0-9]*$/u.test(version);
|
||||
const parsed = parseReleaseVersion(version);
|
||||
if (parsed === null || parsed.version !== version) {
|
||||
throw new Error(`unsupported release version: ${version}`);
|
||||
}
|
||||
if (intent === "main-qualification") {
|
||||
return { profile: "full", purpose: "main-qualification", soak: true, tag: null };
|
||||
}
|
||||
@@ -46,13 +161,13 @@ export function deriveReleasePlanPolicy(
|
||||
tag: `v${version}`,
|
||||
};
|
||||
}
|
||||
return prerelease
|
||||
? { profile: "beta", purpose: "beta-publish", soak: false, tag: `v${version}` }
|
||||
: { profile: "stable", purpose: "stable-publish", soak: true, tag: `v${version}` };
|
||||
return parsed.channel === "stable"
|
||||
? { profile: "stable", purpose: "stable-publish", soak: true, tag: `v${version}` }
|
||||
: { profile: "beta", purpose: "beta-publish", soak: false, tag: `v${version}` };
|
||||
}
|
||||
|
||||
function collectAllowedGroups(repoRoot: string): string[] {
|
||||
const workflow = parseYaml(readFileSync(resolve(repoRoot, WORKFLOW_PATH), "utf8")) as {
|
||||
function collectAllowedGroups(workflowText: string): string[] {
|
||||
const workflow = parseYaml(workflowText) as {
|
||||
on?: { workflow_dispatch?: { inputs?: { rerun_group?: { options?: unknown } } } };
|
||||
};
|
||||
const options = workflow.on?.workflow_dispatch?.inputs?.rerun_group?.options;
|
||||
@@ -61,24 +176,26 @@ function collectAllowedGroups(repoRoot: string): string[] {
|
||||
options.length === 0 ||
|
||||
options.some((entry) => typeof entry !== "string" || !entry)
|
||||
) {
|
||||
throw new Error(`${WORKFLOW_PATH} must declare rerun_group choice options`);
|
||||
throw new Error(`${VALIDATION_WORKFLOW_PATH} must declare rerun_group choice options`);
|
||||
}
|
||||
return [...new Set(options)].toSorted((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
const groups = [...new Set(options)];
|
||||
if (groups.length !== options.length) {
|
||||
throw new Error(`${VALIDATION_WORKFLOW_PATH} rerun_group options must be unique`);
|
||||
}
|
||||
return groups.toSorted(compareAscii);
|
||||
}
|
||||
|
||||
function collectPackageInventory(repoRoot: string, version: string) {
|
||||
function collectPackageInventory(snapshotRoot: string, version: string) {
|
||||
const packages = new Map<string, { name: string; version: string; targets: Set<string> }>();
|
||||
packages.set("openclaw", { name: "openclaw", version, targets: new Set(["npm"]) });
|
||||
for (const plugin of collectPublishablePluginPackages(repoRoot)) {
|
||||
for (const plugin of collectPublishablePluginPackages(snapshotRoot)) {
|
||||
packages.set(plugin.packageName, {
|
||||
name: plugin.packageName,
|
||||
version: plugin.version,
|
||||
targets: new Set(["npm"]),
|
||||
});
|
||||
}
|
||||
for (const plugin of collectClawHubPublishablePluginPackages(repoRoot)) {
|
||||
for (const plugin of collectClawHubPublishablePluginPackages(snapshotRoot)) {
|
||||
const existing = packages.get(plugin.packageName);
|
||||
if (existing && existing.version !== plugin.version) {
|
||||
throw new Error(`plugin inventory version mismatch for ${plugin.packageName}`);
|
||||
@@ -95,95 +212,137 @@ function collectPackageInventory(repoRoot: string, version: string) {
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
version: entry.version,
|
||||
targets: [...entry.targets].toSorted(),
|
||||
targets: [...entry.targets].toSorted(compareAscii),
|
||||
}))
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
.toSorted((left, right) => compareAscii(left.name, right.name));
|
||||
}
|
||||
|
||||
function collectPlatformInventory(repoRoot: string) {
|
||||
const inventory = readJson(resolve(repoRoot, INVENTORY_PATH)) as {
|
||||
schema?: unknown;
|
||||
platforms?: unknown;
|
||||
function collectPlatformSources(workflowText: string) {
|
||||
const platforms = new Map<string, string>();
|
||||
const promotionPattern = /promote_([a-z0-9_]+)_release_assets?\(\)\s*\{([\s\S]*?)^\s*\}/gmu;
|
||||
const dispatchPattern =
|
||||
/dispatch_workflow(?:_at_ref)?\s+(?:(?:"[^"]+"|'[^']+')\s+){0,2}([a-z0-9][a-z0-9-]+\.yml)/u;
|
||||
for (const match of workflowText.matchAll(promotionPattern)) {
|
||||
const id = match[1]?.replaceAll("_", "-");
|
||||
const workflowName = dispatchPattern.exec(match[2] ?? "")?.[1];
|
||||
if (!id || !workflowName) {
|
||||
throw new Error(`${PUBLICATION_WORKFLOW_PATH} has an invalid platform promotion function`);
|
||||
}
|
||||
platforms.set(id, `.github/workflows/${workflowName}`);
|
||||
}
|
||||
const workflow = parseYaml(workflowText) as {
|
||||
jobs?: Record<string, { uses?: unknown }>;
|
||||
};
|
||||
if (
|
||||
inventory.schema !== "openclaw.release-plan-inventory.v1" ||
|
||||
!Array.isArray(inventory.platforms)
|
||||
) {
|
||||
throw new Error(`${INVENTORY_PATH} must use openclaw.release-plan-inventory.v1`);
|
||||
for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
|
||||
if (!jobId.startsWith("publish_") || typeof job.uses !== "string") {
|
||||
continue;
|
||||
}
|
||||
const match = /^\.\/\.github\/workflows\/([a-z0-9][a-z0-9-]+\.yml)$/u.exec(job.uses);
|
||||
if (!match?.[1]) {
|
||||
throw new Error(`${PUBLICATION_WORKFLOW_PATH} has an invalid reusable publication workflow`);
|
||||
}
|
||||
platforms.set(
|
||||
jobId.slice("publish_".length).replaceAll("_", "-"),
|
||||
`.github/workflows/${match[1]}`,
|
||||
);
|
||||
}
|
||||
const platforms = inventory.platforms.map((entry) => {
|
||||
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
|
||||
throw new Error(`${INVENTORY_PATH} contains an invalid platform entry`);
|
||||
}
|
||||
const platform = entry as Record<string, unknown>;
|
||||
if (typeof platform.id !== "string" || typeof platform.source !== "string") {
|
||||
throw new Error(`${INVENTORY_PATH} contains an invalid platform entry`);
|
||||
}
|
||||
if (!existsSync(resolve(repoRoot, platform.source))) {
|
||||
throw new Error(`release platform source does not exist: ${platform.source}`);
|
||||
}
|
||||
return { id: platform.id, source: platform.source };
|
||||
});
|
||||
return platforms.toSorted((left, right) => left.id.localeCompare(right.id));
|
||||
if (platforms.size === 0) {
|
||||
throw new Error(`${PUBLICATION_WORKFLOW_PATH} does not declare platform publication workflows`);
|
||||
}
|
||||
return [...platforms.entries()].toSorted(([left], [right]) => compareAscii(left, right));
|
||||
}
|
||||
|
||||
export function produceReleasePlan(params: {
|
||||
repoRoot?: string;
|
||||
intent: ReleasePlanIntent;
|
||||
toolingFullRef: string;
|
||||
}): ReleasePlan {
|
||||
const repoRoot = resolve(params.repoRoot ?? ".");
|
||||
const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const worktreeStatus = execFileSync("git", ["status", "--porcelain", "--untracked-files=all"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
function collectPlatformInventory(repoRoot: string, toolingSha: string, workflowText: string) {
|
||||
return collectPlatformSources(workflowText).map(([id, source]) => {
|
||||
if (!gitPathExists(repoRoot, toolingSha, source)) {
|
||||
throw new Error(`release platform workflow does not exist at tooling SHA: ${source}`);
|
||||
}
|
||||
return { id, source };
|
||||
});
|
||||
if (worktreeStatus) {
|
||||
throw new Error("release plan source checkout must be clean");
|
||||
}
|
||||
|
||||
function readCandidateInventory(repoRoot: string, candidateSha: string) {
|
||||
return withCandidateSnapshot(repoRoot, candidateSha, (snapshotRoot) => {
|
||||
const rootPackage = JSON.parse(readFileSync(join(snapshotRoot, "package.json"), "utf8")) as {
|
||||
version?: unknown;
|
||||
};
|
||||
if (typeof rootPackage.version !== "string" || !rootPackage.version) {
|
||||
throw new Error("candidate package.json version is required");
|
||||
}
|
||||
return {
|
||||
version: rootPackage.version,
|
||||
packages: collectPackageInventory(snapshotRoot, rootPackage.version),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveSource(params: ReleasePlanSource) {
|
||||
const repoRoot = resolve(params.repoRoot ?? ".");
|
||||
const candidateSha = requireExactSha(params.candidateSha, "candidate SHA");
|
||||
const toolingSha = requireExactSha(params.toolingSha, "tooling SHA");
|
||||
const toolingFullRef = requireQualifiedRef(params.toolingFullRef, "tooling full ref");
|
||||
if (resolveCommit(repoRoot, params.candidateRef, "candidate ref") !== candidateSha) {
|
||||
throw new Error("candidate ref does not resolve to the requested candidate SHA");
|
||||
}
|
||||
const toolingMatch = /^refs\/(heads|tags)\/(.+)$/u.exec(params.toolingFullRef);
|
||||
if (!toolingMatch) {
|
||||
throw new Error("tooling full ref must be a qualified branch or tag ref");
|
||||
if (resolveCommit(repoRoot, toolingFullRef, "tooling full ref") !== toolingSha) {
|
||||
throw new Error("tooling full ref does not resolve to the requested tooling SHA");
|
||||
}
|
||||
const toolingSha = execFileSync(
|
||||
"git",
|
||||
["rev-parse", "--verify", `${params.toolingFullRef}^{commit}`],
|
||||
{ cwd: repoRoot, encoding: "utf8" },
|
||||
).trim();
|
||||
const rootPackage = readJson(resolve(repoRoot, "package.json")) as { version?: unknown };
|
||||
if (typeof rootPackage.version !== "string" || !rootPackage.version) {
|
||||
throw new Error("package.json version is required");
|
||||
return { candidateSha, repoRoot, toolingFullRef, toolingSha };
|
||||
}
|
||||
|
||||
export function produceReleasePlan(params: ReleasePlanSource): ReleasePlan {
|
||||
const { candidateSha, repoRoot, toolingFullRef, toolingSha } = resolveSource(params);
|
||||
const candidate = readCandidateInventory(repoRoot, candidateSha);
|
||||
const policy = deriveReleasePlanPolicy(params.intent, candidate.version);
|
||||
const expectedCandidateRef =
|
||||
params.intent === "main-qualification" ? candidateSha : `refs/tags/v${candidate.version}`;
|
||||
if (params.candidateRef !== expectedCandidateRef) {
|
||||
throw new Error(`${params.intent} candidate ref must be ${expectedCandidateRef}`);
|
||||
}
|
||||
const policy = deriveReleasePlanPolicy(params.intent, rootPackage.version);
|
||||
if (!gitPathExists(repoRoot, toolingSha, PRODUCER_PATH)) {
|
||||
throw new Error(`${PRODUCER_PATH} is missing from tooling SHA ${toolingSha}`);
|
||||
}
|
||||
const validationWorkflow = readGitText(repoRoot, toolingSha, VALIDATION_WORKFLOW_PATH);
|
||||
const publicationWorkflow = readGitText(repoRoot, toolingSha, PUBLICATION_WORKFLOW_PATH);
|
||||
return validateReleasePlan({
|
||||
schema: RELEASE_PLAN_SCHEMA,
|
||||
release_id: rootPackage.version,
|
||||
version: rootPackage.version,
|
||||
release_id: candidate.version,
|
||||
version: candidate.version,
|
||||
tag: policy.tag,
|
||||
candidate_sha: candidateSha,
|
||||
target_context_ref: policy.tag ? `refs/tags/${policy.tag}` : candidateSha,
|
||||
target_context_ref: expectedCandidateRef,
|
||||
purpose: policy.purpose,
|
||||
tooling: {
|
||||
repository: REPOSITORY,
|
||||
workflow_path: WORKFLOW_PATH,
|
||||
ref: params.toolingFullRef,
|
||||
workflow_path: VALIDATION_WORKFLOW_PATH,
|
||||
ref: toolingFullRef,
|
||||
sha: toolingSha,
|
||||
},
|
||||
validation: {
|
||||
profile: policy.profile,
|
||||
soak: policy.soak,
|
||||
allowed_groups: collectAllowedGroups(repoRoot),
|
||||
allowed_groups: collectAllowedGroups(validationWorkflow),
|
||||
exceptions: [],
|
||||
},
|
||||
inventory: {
|
||||
packages: collectPackageInventory(repoRoot, rootPackage.version),
|
||||
platforms: collectPlatformInventory(repoRoot),
|
||||
packages: candidate.packages,
|
||||
platforms: collectPlatformInventory(repoRoot, toolingSha, publicationWorkflow),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyReleasePlanLock(
|
||||
lockJson: string,
|
||||
params: ReleasePlanSource,
|
||||
): ReleasePlanLock {
|
||||
const lock = parseReleasePlanLockJson(lockJson);
|
||||
const expectedPlan = produceReleasePlan(params);
|
||||
if (canonicalReleasePlanJson(lock.plan) !== canonicalReleasePlanJson(expectedPlan)) {
|
||||
throw new Error("release plan does not match repository-derived authority");
|
||||
}
|
||||
return lock;
|
||||
}
|
||||
|
||||
function requiredOption(args: string[], name: string): string {
|
||||
const index = args.indexOf(name);
|
||||
const value = index >= 0 ? args[index + 1] : undefined;
|
||||
@@ -201,6 +360,9 @@ function main() {
|
||||
}
|
||||
const plan = produceReleasePlan({
|
||||
intent,
|
||||
candidateSha: requiredOption(args, "--candidate-sha"),
|
||||
candidateRef: requiredOption(args, "--candidate-ref"),
|
||||
toolingSha: requiredOption(args, "--tooling-sha"),
|
||||
toolingFullRef: requiredOption(args, "--tooling-full-ref"),
|
||||
});
|
||||
process.stdout.write(canonicalReleasePlanLockJson(createReleasePlanLock(plan)));
|
||||
|
||||
@@ -172,9 +172,9 @@ export function validateFullReleaseValidationEvidence({
|
||||
);
|
||||
}
|
||||
|
||||
if (![3, 4].includes(manifest.version)) {
|
||||
if (manifest.version !== 3) {
|
||||
throw new Error(
|
||||
`Full release validation manifest must use version 3 or 4, got ${displayValue(manifest.version)}.`,
|
||||
`Full release validation manifest must use version 3, got ${displayValue(manifest.version)}.`,
|
||||
);
|
||||
}
|
||||
const manifestChecks = [
|
||||
|
||||
+1
-52
@@ -1,52 +1 @@
|
||||
{
|
||||
"schema": "openclaw.release-plan-lock.v1",
|
||||
"digest": "sha256:f48b6de82045491d086c8fafb8217ea565a99a87a01e96bd01a30c9690f89462",
|
||||
"plan": {
|
||||
"schema": "openclaw.release-plan.v1",
|
||||
"release_id": "2026.8.1-beta.2",
|
||||
"version": "2026.8.1-beta.2",
|
||||
"tag": "v2026.8.1-beta.2",
|
||||
"candidate_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"target_context_ref": "refs/tags/v2026.8.1-beta.2",
|
||||
"purpose": "beta-publish",
|
||||
"tooling": {
|
||||
"repository": "openclaw/openclaw",
|
||||
"workflow_path": ".github/workflows/full-release-validation.yml",
|
||||
"ref": "refs/tags/release-publish/bbbbbbbbbbbb-123",
|
||||
"sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
},
|
||||
"validation": {
|
||||
"profile": "beta",
|
||||
"soak": false,
|
||||
"allowed_groups": ["all", "ci", "package"]
|
||||
},
|
||||
"inventory": {
|
||||
"packages": [
|
||||
{
|
||||
"name": "@openclaw/example",
|
||||
"version": "2026.8.1-beta.2",
|
||||
"targets": ["clawhub", "npm"]
|
||||
},
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.8.1-beta.2",
|
||||
"targets": ["npm"]
|
||||
}
|
||||
],
|
||||
"platforms": [
|
||||
{
|
||||
"id": "android",
|
||||
"source": ".github/workflows/android-release.yml"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": ".github/workflows/docker-release.yml"
|
||||
},
|
||||
{
|
||||
"id": "windows",
|
||||
"source": ".github/workflows/windows-node-release.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
{"digest":"sha256:e621017c5bb1f9d70974d33e098f2be653f28b1804d925d8a7f8dabe8bc49205","plan":{"candidate_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","inventory":{"packages":[{"name":"@openclaw/example","targets":["clawhub","npm"],"version":"2026.8.1-beta.2"},{"name":"openclaw","targets":["npm"],"version":"2026.8.1-beta.2"}],"platforms":[{"id":"android","source":".github/workflows/android-release.yml"},{"id":"docker","source":".github/workflows/docker-release.yml"},{"id":"vcr","source":".github/workflows/vercel-container-registry-publish.yml"},{"id":"windows","source":".github/workflows/windows-node-release.yml"}]},"purpose":"beta-publish","release_id":"2026.8.1-beta.2","schema":"openclaw.release-plan.v1","tag":"v2026.8.1-beta.2","target_context_ref":"refs/tags/v2026.8.1-beta.2","tooling":{"ref":"refs/tags/release-publish/bbbbbbbbbbbb-123","repository":"openclaw/openclaw","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","workflow_path":".github/workflows/full-release-validation.yml"},"validation":{"allowed_groups":["all","ci","package"],"exceptions":[],"profile":"beta","soak":false},"version":"2026.8.1-beta.2"},"schema":"openclaw.release-plan-lock.v1"}
|
||||
|
||||
+1
-48
@@ -1,48 +1 @@
|
||||
{
|
||||
"schema": "openclaw.release-plan.v1",
|
||||
"release_id": "2026.8.1-beta.2",
|
||||
"version": "2026.8.1-beta.2",
|
||||
"tag": "v2026.8.1-beta.2",
|
||||
"candidate_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"target_context_ref": "refs/tags/v2026.8.1-beta.2",
|
||||
"purpose": "beta-publish",
|
||||
"tooling": {
|
||||
"repository": "openclaw/openclaw",
|
||||
"workflow_path": ".github/workflows/full-release-validation.yml",
|
||||
"ref": "refs/tags/release-publish/bbbbbbbbbbbb-123",
|
||||
"sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||
},
|
||||
"validation": {
|
||||
"profile": "beta",
|
||||
"soak": false,
|
||||
"allowed_groups": ["all", "ci", "package"]
|
||||
},
|
||||
"inventory": {
|
||||
"packages": [
|
||||
{
|
||||
"name": "@openclaw/example",
|
||||
"version": "2026.8.1-beta.2",
|
||||
"targets": ["clawhub", "npm"]
|
||||
},
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.8.1-beta.2",
|
||||
"targets": ["npm"]
|
||||
}
|
||||
],
|
||||
"platforms": [
|
||||
{
|
||||
"id": "android",
|
||||
"source": ".github/workflows/android-release.yml"
|
||||
},
|
||||
{
|
||||
"id": "docker",
|
||||
"source": ".github/workflows/docker-release.yml"
|
||||
},
|
||||
{
|
||||
"id": "windows",
|
||||
"source": ".github/workflows/windows-node-release.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
{"candidate_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","inventory":{"packages":[{"name":"@openclaw/example","targets":["clawhub","npm"],"version":"2026.8.1-beta.2"},{"name":"openclaw","targets":["npm"],"version":"2026.8.1-beta.2"}],"platforms":[{"id":"android","source":".github/workflows/android-release.yml"},{"id":"docker","source":".github/workflows/docker-release.yml"},{"id":"vcr","source":".github/workflows/vercel-container-registry-publish.yml"},{"id":"windows","source":".github/workflows/windows-node-release.yml"}]},"purpose":"beta-publish","release_id":"2026.8.1-beta.2","schema":"openclaw.release-plan.v1","tag":"v2026.8.1-beta.2","target_context_ref":"refs/tags/v2026.8.1-beta.2","tooling":{"ref":"refs/tags/release-publish/bbbbbbbbbbbb-123","repository":"openclaw/openclaw","sha":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","workflow_path":".github/workflows/full-release-validation.yml"},"validation":{"allowed_groups":["all","ci","package"],"exceptions":[],"profile":"beta","soak":false},"version":"2026.8.1-beta.2"}
|
||||
|
||||
@@ -420,7 +420,7 @@ function rawManifest({
|
||||
rerunGroup?: string;
|
||||
runId?: string;
|
||||
targetSha?: string;
|
||||
version?: 2 | 3 | 4;
|
||||
version?: 2 | 3;
|
||||
workflowFullRef?: string;
|
||||
workflowRefType?: "branch" | "tag";
|
||||
workflowSha?: string;
|
||||
@@ -436,7 +436,7 @@ function rawManifest({
|
||||
targetRef?: string;
|
||||
targetSha: string;
|
||||
validationInputs: Record<string, string>;
|
||||
version: 2 | 3 | 4;
|
||||
version: 2 | 3;
|
||||
workflowFullRef?: string;
|
||||
workflowName: string;
|
||||
workflowRef: string;
|
||||
@@ -482,7 +482,7 @@ function rawManifest({
|
||||
workflowName: "Full Release Validation",
|
||||
workflowRef: "main",
|
||||
...(workflowSha ? { workflowSha } : {}),
|
||||
...(version >= 3
|
||||
...(version === 3
|
||||
? {
|
||||
workflowFullRef: workflowFullRef ?? "refs/heads/main",
|
||||
workflowRefType: workflowRefType ?? "branch",
|
||||
@@ -500,7 +500,7 @@ function trustedMainPackageFixture({
|
||||
workflowRefType,
|
||||
workflowSha = "0".repeat(40),
|
||||
}: {
|
||||
manifestVersion?: 2 | 3 | 4;
|
||||
manifestVersion?: 2 | 3;
|
||||
parentPath?: string;
|
||||
targetSha?: string;
|
||||
workflowFullRef?: string;
|
||||
@@ -1327,42 +1327,39 @@ describe("release CI summary child correlation", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each([3, 4] as const)(
|
||||
"accepts v%s SHA-pinned producer identity with exact-target evidence reuse",
|
||||
(manifestVersion) => {
|
||||
const workflowSha = "7".repeat(40);
|
||||
const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
|
||||
const fixture = trustedMainPackageFixture({
|
||||
manifestVersion,
|
||||
workflowFullRef: `refs/heads/${workflowRef}`,
|
||||
workflowRef,
|
||||
workflowSha,
|
||||
});
|
||||
fixture.manifest.targetRef = fixture.targetSha;
|
||||
fixture.manifest.evidenceReuse = {
|
||||
changedPaths: [],
|
||||
evidenceSha: fixture.targetSha,
|
||||
policy: "exact-target-full-validation-v1",
|
||||
runId: "29071366024",
|
||||
selectedRunId: "29071366024",
|
||||
};
|
||||
it("accepts SHA-pinned producer identity with exact-target evidence reuse", () => {
|
||||
const workflowSha = "7".repeat(40);
|
||||
const workflowRef = `release-ci/${workflowSha.slice(0, 12)}-1783705000000`;
|
||||
const fixture = trustedMainPackageFixture({
|
||||
manifestVersion: 3,
|
||||
workflowFullRef: `refs/heads/${workflowRef}`,
|
||||
workflowRef,
|
||||
workflowSha,
|
||||
});
|
||||
fixture.manifest.targetRef = fixture.targetSha;
|
||||
fixture.manifest.evidenceReuse = {
|
||||
changedPaths: [],
|
||||
evidenceSha: fixture.targetSha,
|
||||
policy: "exact-target-full-validation-v1",
|
||||
runId: "29071366024",
|
||||
selectedRunId: "29071366024",
|
||||
};
|
||||
|
||||
expect(
|
||||
validateTrustedProducerIdentity(
|
||||
{
|
||||
manifest: fixture.manifest,
|
||||
parentRun: fixture.parentRun,
|
||||
},
|
||||
fixture.client,
|
||||
{ sourceSha: "c".repeat(40) },
|
||||
"main",
|
||||
),
|
||||
).toMatchObject({
|
||||
producerOnTrustedMainLineage: true,
|
||||
workflowRefProof: `manifest-v${manifestVersion}-sha-pinned-main-ancestry`,
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(
|
||||
validateTrustedProducerIdentity(
|
||||
{
|
||||
manifest: fixture.manifest,
|
||||
parentRun: fixture.parentRun,
|
||||
},
|
||||
fixture.client,
|
||||
{ sourceSha: "c".repeat(40) },
|
||||
"main",
|
||||
),
|
||||
).toMatchObject({
|
||||
producerOnTrustedMainLineage: true,
|
||||
workflowRefProof: "manifest-v3-sha-pinned-main-ancestry",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a SHA-pinned evidenceReuse field even when false", () => {
|
||||
const workflowSha = "7".repeat(40);
|
||||
|
||||
@@ -9,97 +9,149 @@ import {
|
||||
RELEASE_PLAN_CANONICALIZATION,
|
||||
releasePlanDigest,
|
||||
validateReleasePlan,
|
||||
validateReleasePlanLock,
|
||||
validateValidationAttemptReceipt,
|
||||
validateValidationAttemptRequest,
|
||||
} from "../../scripts/release-plan-contract.mjs";
|
||||
|
||||
const fixtureDir = resolve("test/fixtures");
|
||||
const sourceFixture = JSON.parse(
|
||||
readFileSync(resolve(fixtureDir, "release-plan-v1.source.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const lockFixture = JSON.parse(
|
||||
readFileSync(resolve(fixtureDir, "release-plan-lock-v1.compatibility.json"), "utf8"),
|
||||
) as Record<string, unknown>;
|
||||
const sourceText = readFileSync(resolve(fixtureDir, "release-plan-v1.source.json"), "utf8");
|
||||
const lockText = readFileSync(
|
||||
resolve(fixtureDir, "release-plan-lock-v1.compatibility.json"),
|
||||
"utf8",
|
||||
);
|
||||
const sourceFixture = JSON.parse(sourceText) as Record<string, unknown>;
|
||||
const lockFixture = JSON.parse(lockText) as Record<string, unknown>;
|
||||
|
||||
describe("release plan contract", () => {
|
||||
it("matches the exact public and private-consumer compatibility fixture", () => {
|
||||
const canonical = canonicalReleasePlanJson(sourceFixture);
|
||||
|
||||
it("pins exact canonical source and lock bytes as the cross-repo golden fixture", () => {
|
||||
expect(RELEASE_PLAN_CANONICALIZATION).toBe("ascii-sorted-compact-json-trailing-newline-v1");
|
||||
expect(sourceText).toBe(canonicalReleasePlanJson(sourceFixture));
|
||||
expect(lockText).toBe(canonicalReleasePlanLockJson(lockFixture));
|
||||
expect(createReleasePlanLock(sourceFixture)).toEqual(lockFixture);
|
||||
expect(validateReleasePlanLock(lockFixture).plan).toEqual(sourceFixture);
|
||||
expect(Buffer.byteLength(canonical, "ascii")).toBe(943);
|
||||
expect(releasePlanDigest(sourceFixture)).toBe(
|
||||
"sha256:f48b6de82045491d086c8fafb8217ea565a99a87a01e96bd01a30c9690f89462",
|
||||
expect(parseReleasePlanLockJson(lockText)).toEqual(lockFixture);
|
||||
expect(lockText.endsWith("\n")).toBe(true);
|
||||
expect(lockText.slice(0, -1)).toMatch(/^[\x20-\x7e]+$/u);
|
||||
});
|
||||
|
||||
it("rejects duplicate, reordered, pretty, CRLF, and non-ASCII lock bytes", () => {
|
||||
const duplicate = lockText.replace(
|
||||
'{"digest":',
|
||||
`{"digest":"${String(lockFixture.digest)}","digest":`,
|
||||
);
|
||||
expect(canonical.endsWith("\n")).toBe(true);
|
||||
expect(canonical.slice(0, -1)).not.toMatch(/[\r\n]/u);
|
||||
expect(canonical.slice(0, -1)).toMatch(/^[\x20-\x7e]+$/u);
|
||||
});
|
||||
|
||||
it("round-trips the exact outer lock envelope", () => {
|
||||
const canonicalLock = canonicalReleasePlanLockJson(lockFixture);
|
||||
expect(parseReleasePlanLockJson(canonicalLock)).toEqual(lockFixture);
|
||||
expect(Object.keys(lockFixture).toSorted()).toEqual(["digest", "plan", "schema"]);
|
||||
});
|
||||
|
||||
it("rejects duplicate keys, unknown authority, and non-ASCII data", () => {
|
||||
const canonicalLock = canonicalReleasePlanLockJson(lockFixture);
|
||||
expect(() => parseReleasePlanLockJson(duplicate)).toThrow("duplicate key");
|
||||
expect(() =>
|
||||
parseReleasePlanLockJson(
|
||||
canonicalLock.replace('{"digest":', `{"digest":"${String(lockFixture.digest)}","digest":`),
|
||||
`${JSON.stringify({
|
||||
schema: lockFixture.schema,
|
||||
plan: lockFixture.plan,
|
||||
digest: lockFixture.digest,
|
||||
})}\n`,
|
||||
),
|
||||
).toThrow("duplicate key");
|
||||
).toThrow("canonical bytes");
|
||||
expect(() => parseReleasePlanLockJson(`${JSON.stringify(lockFixture, null, 2)}\n`)).toThrow(
|
||||
"compact printable ASCII",
|
||||
);
|
||||
expect(() => parseReleasePlanLockJson(lockText.replace(/\n$/u, "\r\n"))).toThrow(
|
||||
"exactly one trailing LF",
|
||||
);
|
||||
expect(() =>
|
||||
parseReleasePlanLockJson(lockText.replace("openclaw/openclaw", "opénclaw")),
|
||||
).toThrow("printable ASCII");
|
||||
});
|
||||
|
||||
it("enforces the purpose, version, tag, and target context matrix", () => {
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
purpose: "stable-publish",
|
||||
validation: {
|
||||
allowed_groups: ["all", "ci", "package"],
|
||||
exceptions: [],
|
||||
profile: "stable",
|
||||
soak: true,
|
||||
},
|
||||
}),
|
||||
).toThrow("stable-publish release plan version must be stable");
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
purpose: "main-qualification",
|
||||
tag: null,
|
||||
target_context_ref: "refs/tags/null",
|
||||
validation: {
|
||||
allowed_groups: ["all", "ci", "package"],
|
||||
exceptions: [],
|
||||
profile: "full",
|
||||
soak: true,
|
||||
},
|
||||
}),
|
||||
).toThrow("candidate SHA context");
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
tag: "v2026.8.1-beta.3",
|
||||
}),
|
||||
).toThrow("exact version tag context");
|
||||
});
|
||||
|
||||
it("rejects unknown authority, invalid ordering, and unsupported versions", () => {
|
||||
expect(() => validateReleasePlan({ ...sourceFixture, run_id: "123" })).toThrow(
|
||||
"release plan keys must be exactly",
|
||||
);
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
release_id: "2026.8.1-béta.2",
|
||||
version: "2026.08.1-beta.2",
|
||||
release_id: "2026.08.1-beta.2",
|
||||
tag: "v2026.08.1-beta.2",
|
||||
target_context_ref: "refs/tags/v2026.08.1-beta.2",
|
||||
}),
|
||||
).toThrow("printable ASCII");
|
||||
});
|
||||
|
||||
it("keeps ValidationAttempt state outside ReleasePlan", () => {
|
||||
const plan = validateReleasePlan(sourceFixture);
|
||||
expect(plan).not.toHaveProperty("attempt");
|
||||
expect(plan).not.toHaveProperty("run_id");
|
||||
expect(plan).not.toHaveProperty("timestamp");
|
||||
expect(plan).not.toHaveProperty("rerun_group");
|
||||
expect(plan).not.toHaveProperty("filters");
|
||||
expect(plan).not.toHaveProperty("local_path");
|
||||
});
|
||||
|
||||
it("rejects unsorted inventory and purpose-policy drift", () => {
|
||||
).toThrow("supported release version");
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
inventory: {
|
||||
...(sourceFixture.inventory as Record<string, unknown>),
|
||||
packages: [
|
||||
{
|
||||
name: "openclaw",
|
||||
version: "2026.8.1-beta.2",
|
||||
targets: ["npm"],
|
||||
},
|
||||
{ name: "openclaw", targets: ["npm"], version: "2026.8.1-beta.2" },
|
||||
{
|
||||
name: "@openclaw/example",
|
||||
version: "2026.8.1-beta.2",
|
||||
targets: ["clawhub", "npm"],
|
||||
version: "2026.8.1-beta.2",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toThrow("packages must have unique names in ascending ASCII order");
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
validation: {
|
||||
allowed_groups: ["all"],
|
||||
profile: "full",
|
||||
soak: true,
|
||||
},
|
||||
).toThrow("ascending ASCII order");
|
||||
});
|
||||
|
||||
it("keeps ValidationAttempt request and receipt state outside ReleasePlan", () => {
|
||||
const plan = validateReleasePlan(sourceFixture);
|
||||
const planDigest = releasePlanDigest(plan);
|
||||
expect(plan).not.toHaveProperty("run_id");
|
||||
expect(plan).not.toHaveProperty("rerun_group");
|
||||
expect(
|
||||
validateValidationAttemptRequest({
|
||||
schema: "openclaw.validation-attempt-request.v1",
|
||||
plan_digest: planDigest,
|
||||
rerun_group: "package",
|
||||
filters: { platform: "linux", package: "openclaw" },
|
||||
fail_fast: false,
|
||||
reuse_evidence: true,
|
||||
}),
|
||||
).toThrow("beta-publish validation policy is invalid");
|
||||
).toMatchObject({ plan_digest: planDigest, rerun_group: "package" });
|
||||
expect(
|
||||
validateValidationAttemptReceipt({
|
||||
schema: "openclaw.validation-attempt-receipt.v1",
|
||||
plan_digest: planDigest,
|
||||
request_digest: `sha256:${"b".repeat(64)}`,
|
||||
run_id: "123",
|
||||
run_attempt: "2",
|
||||
workflow_ref: "release-ci/example",
|
||||
workflow_full_ref: "refs/heads/release-ci/example",
|
||||
workflow_sha: "c".repeat(40),
|
||||
target_sha: "a".repeat(40),
|
||||
}),
|
||||
).toMatchObject({ run_attempt: "2", run_id: "123" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,58 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalReleasePlanLockJson,
|
||||
createReleasePlanLock,
|
||||
} from "../../scripts/release-plan-contract.mjs";
|
||||
import {
|
||||
deriveReleasePlanPolicy,
|
||||
produceReleasePlan,
|
||||
verifyReleasePlanLock,
|
||||
type ReleasePlanIntent,
|
||||
} from "../../scripts/release-plan-producer.mts";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function createFixtureRepo() {
|
||||
const root = mkdtempSync(join(tmpdir(), "openclaw-release-plan-"));
|
||||
tempDirs.push(root);
|
||||
mkdirSync(join(root, "extensions"), { recursive: true });
|
||||
mkdirSync(join(root, ".github/workflows"), { recursive: true });
|
||||
mkdirSync(join(root, "scripts"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(root, "package.json"),
|
||||
JSON.stringify({ name: "openclaw", version: "2026.8.1" }),
|
||||
function writeFixture(root: string, path: string, content: string) {
|
||||
const target = join(root, path);
|
||||
mkdirSync(dirname(target), { recursive: true });
|
||||
writeFileSync(target, content);
|
||||
}
|
||||
|
||||
function commit(root: string, message: string): string {
|
||||
execFileSync("git", ["add", "."], { cwd: root });
|
||||
execFileSync(
|
||||
"git",
|
||||
[
|
||||
"-c",
|
||||
"user.name=OpenClaw Test",
|
||||
"-c",
|
||||
"user.email=test@example.invalid",
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
message,
|
||||
],
|
||||
{ cwd: root },
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, ".github/workflows/full-release-validation.yml"),
|
||||
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function createFixtureRepo(version = "2026.8.1-beta.2") {
|
||||
const root = tempDirs.make("openclaw-release-plan-");
|
||||
execFileSync("git", ["init", "-q", "-b", "tooling"], { cwd: root });
|
||||
|
||||
writeFixture(root, "package.json", JSON.stringify({ name: "openclaw", version }));
|
||||
const candidateSha = commit(root, "candidate");
|
||||
const candidateRef = `refs/tags/v${version}`;
|
||||
execFileSync("git", ["tag", `v${version}`, candidateSha], { cwd: root });
|
||||
|
||||
writeFixture(
|
||||
root,
|
||||
".github/workflows/full-release-validation.yml",
|
||||
[
|
||||
"on:",
|
||||
" workflow_dispatch:",
|
||||
@@ -34,48 +65,61 @@ function createFixtureRepo() {
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
const platforms = [
|
||||
["android", ".github/workflows/android-release.yml"],
|
||||
["docker", ".github/workflows/docker-release.yml"],
|
||||
["windows", ".github/workflows/windows-node-release.yml"],
|
||||
] as const;
|
||||
for (const [, source] of platforms) {
|
||||
writeFileSync(join(root, source), "name: fixture\n");
|
||||
}
|
||||
writeFileSync(
|
||||
join(root, "scripts/release-plan-inventory.json"),
|
||||
JSON.stringify({
|
||||
schema: "openclaw.release-plan-inventory.v1",
|
||||
platforms: platforms.map(([id, source]) => ({ id, source })),
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["init", "-q", "-b", "tooling"], { cwd: root });
|
||||
execFileSync("git", ["add", "."], { cwd: root });
|
||||
execFileSync(
|
||||
"git",
|
||||
writeFixture(
|
||||
root,
|
||||
".github/workflows/openclaw-release-publish.yml",
|
||||
[
|
||||
"-c",
|
||||
"user.name=OpenClaw Test",
|
||||
"-c",
|
||||
"user.email=test@example.invalid",
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
{ cwd: root },
|
||||
"name: Release Publish",
|
||||
"jobs:",
|
||||
" publish:",
|
||||
" runs-on: ubuntu-latest",
|
||||
" steps:",
|
||||
" - run: |",
|
||||
" promote_windows_release_assets() {",
|
||||
" dispatch_workflow windows-node-release.yml",
|
||||
" }",
|
||||
" promote_android_release_asset() {",
|
||||
' dispatch_workflow_at_ref "${RELEASE_TAG}" "${TARGET_SHA}" android-release.yml',
|
||||
" }",
|
||||
" dispatch_workflow plugin-npm-release.yml",
|
||||
" publish_docker:",
|
||||
" uses: ./.github/workflows/docker-release.yml",
|
||||
" publish_vcr:",
|
||||
" uses: ./.github/workflows/vercel-container-registry-publish.yml",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
return root;
|
||||
for (const name of [
|
||||
"android-release.yml",
|
||||
"docker-release.yml",
|
||||
"plugin-npm-release.yml",
|
||||
"vercel-container-registry-publish.yml",
|
||||
"windows-node-release.yml",
|
||||
]) {
|
||||
writeFixture(root, `.github/workflows/${name}`, `name: ${name}\n`);
|
||||
}
|
||||
writeFixture(root, "scripts/release-plan-producer.mts", "// tooling-owned fixture\n");
|
||||
writeFixture(root, "package.json", JSON.stringify({ name: "openclaw", version: "2099.1.1" }));
|
||||
const toolingSha = commit(root, "tooling");
|
||||
return { candidateRef, candidateSha, root, toolingSha };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
function sourceParams(
|
||||
fixture: ReturnType<typeof createFixtureRepo>,
|
||||
intent: ReleasePlanIntent = "publish",
|
||||
) {
|
||||
return {
|
||||
repoRoot: fixture.root,
|
||||
intent,
|
||||
candidateSha: fixture.candidateSha,
|
||||
candidateRef: intent === "main-qualification" ? fixture.candidateSha : fixture.candidateRef,
|
||||
toolingSha: fixture.toolingSha,
|
||||
toolingFullRef: "refs/heads/tooling",
|
||||
} as const;
|
||||
}
|
||||
|
||||
describe("release plan producer", () => {
|
||||
it("derives purpose, profile, tag, and soak from intent and version", () => {
|
||||
it("derives purpose, profile, tag, and soak from the canonical version parser", () => {
|
||||
expect(deriveReleasePlanPolicy("publish", "2026.8.1-beta.2")).toEqual({
|
||||
profile: "beta",
|
||||
purpose: "beta-publish",
|
||||
@@ -88,67 +132,92 @@ describe("release plan producer", () => {
|
||||
soak: true,
|
||||
tag: "v2026.8.1",
|
||||
});
|
||||
expect(deriveReleasePlanPolicy("postpublish-confidence", "2026.8.1")).toEqual({
|
||||
profile: "full",
|
||||
purpose: "postpublish-confidence",
|
||||
soak: true,
|
||||
tag: "v2026.8.1",
|
||||
});
|
||||
expect(deriveReleasePlanPolicy("main-qualification", "2026.8.1")).toEqual({
|
||||
profile: "full",
|
||||
purpose: "main-qualification",
|
||||
soak: true,
|
||||
tag: null,
|
||||
});
|
||||
expect(() => deriveReleasePlanPolicy("publish", "2026.08.1")).toThrow(
|
||||
"unsupported release version",
|
||||
);
|
||||
});
|
||||
|
||||
it("derives identity, groups, and inventory from an exact clean checkout", () => {
|
||||
const repoRoot = createFixtureRepo();
|
||||
const sha = execFileSync("git", ["rev-parse", "HEAD"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const plan = produceReleasePlan({
|
||||
repoRoot,
|
||||
intent: "main-qualification",
|
||||
toolingFullRef: "refs/heads/tooling",
|
||||
});
|
||||
it("reads candidate inventory and tooling policy from genuinely different commits", () => {
|
||||
const fixture = createFixtureRepo();
|
||||
expect(fixture.candidateSha).not.toBe(fixture.toolingSha);
|
||||
expect(() =>
|
||||
execFileSync(
|
||||
"git",
|
||||
["cat-file", "-e", `${fixture.candidateSha}:scripts/release-plan-producer.mts`],
|
||||
{ cwd: fixture.root, stdio: "ignore" },
|
||||
),
|
||||
).toThrow();
|
||||
expect(
|
||||
execFileSync(
|
||||
"git",
|
||||
["cat-file", "-e", `${fixture.toolingSha}:scripts/release-plan-producer.mts`],
|
||||
{ cwd: fixture.root, stdio: "ignore" },
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
const plan = produceReleasePlan(sourceParams(fixture));
|
||||
expect(plan).toMatchObject({
|
||||
candidate_sha: sha,
|
||||
purpose: "main-qualification",
|
||||
release_id: "2026.8.1",
|
||||
tag: null,
|
||||
target_context_ref: sha,
|
||||
version: "2026.8.1",
|
||||
candidate_sha: fixture.candidateSha,
|
||||
purpose: "beta-publish",
|
||||
release_id: "2026.8.1-beta.2",
|
||||
tag: "v2026.8.1-beta.2",
|
||||
target_context_ref: fixture.candidateRef,
|
||||
version: "2026.8.1-beta.2",
|
||||
});
|
||||
expect(plan.tooling).toEqual({
|
||||
expect(plan.tooling).toMatchObject({
|
||||
ref: "refs/heads/tooling",
|
||||
repository: "openclaw/openclaw",
|
||||
sha,
|
||||
workflow_path: ".github/workflows/full-release-validation.yml",
|
||||
sha: fixture.toolingSha,
|
||||
});
|
||||
expect(plan.validation.allowed_groups).toEqual(["all", "ci", "package"]);
|
||||
expect(plan.inventory.packages).toEqual([
|
||||
{ name: "openclaw", targets: ["npm"], version: "2026.8.1" },
|
||||
{ name: "openclaw", targets: ["npm"], version: "2026.8.1-beta.2" },
|
||||
]);
|
||||
expect(plan.inventory.platforms.map((platform) => platform.id)).toEqual([
|
||||
"android",
|
||||
"docker",
|
||||
"windows",
|
||||
expect(plan.inventory.platforms).toEqual([
|
||||
{ id: "android", source: ".github/workflows/android-release.yml" },
|
||||
{ id: "docker", source: ".github/workflows/docker-release.yml" },
|
||||
{
|
||||
id: "vcr",
|
||||
source: ".github/workflows/vercel-container-registry-publish.yml",
|
||||
},
|
||||
{ id: "windows", source: ".github/workflows/windows-node-release.yml" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a checkout whose bytes are not represented by candidate HEAD", () => {
|
||||
const repoRoot = createFixtureRepo();
|
||||
writeFileSync(join(repoRoot, "package.json"), '{"name":"openclaw","version":"2026.8.2"}');
|
||||
|
||||
it("requires exact candidate and tooling identity instead of checkout HEAD", () => {
|
||||
const fixture = createFixtureRepo();
|
||||
expect(() =>
|
||||
produceReleasePlan({
|
||||
repoRoot,
|
||||
intent: "publish",
|
||||
toolingFullRef: "refs/heads/tooling",
|
||||
}),
|
||||
).toThrow("source checkout must be clean");
|
||||
produceReleasePlan({ ...sourceParams(fixture), candidateSha: fixture.toolingSha }),
|
||||
).toThrow("candidate ref does not resolve");
|
||||
expect(() =>
|
||||
produceReleasePlan({ ...sourceParams(fixture), toolingSha: fixture.candidateSha }),
|
||||
).toThrow("tooling full ref does not resolve");
|
||||
expect(() =>
|
||||
produceReleasePlan({ ...sourceParams(fixture), candidateRef: "refs/heads/tooling" }),
|
||||
).toThrow("candidate ref does not resolve");
|
||||
});
|
||||
|
||||
it("rejects recomputed locks with partial groups or bogus inventory", () => {
|
||||
const fixture = createFixtureRepo();
|
||||
const params = sourceParams(fixture);
|
||||
const plan = produceReleasePlan(params);
|
||||
const validLock = canonicalReleasePlanLockJson(createReleasePlanLock(plan));
|
||||
expect(verifyReleasePlanLock(validLock, params).plan).toEqual(plan);
|
||||
|
||||
const partialGroups = structuredClone(plan);
|
||||
partialGroups.validation.allowed_groups = ["all", "ci"];
|
||||
const partialPlatforms = structuredClone(plan);
|
||||
partialPlatforms.inventory.platforms = partialPlatforms.inventory.platforms.slice(0, -1);
|
||||
const bogusPackages = structuredClone(plan);
|
||||
bogusPackages.inventory.packages.push({
|
||||
name: "zz-not-published",
|
||||
targets: ["npm"],
|
||||
version: plan.version,
|
||||
});
|
||||
for (const changed of [partialGroups, partialPlatforms, bogusPackages]) {
|
||||
const redigested = canonicalReleasePlanLockJson(createReleasePlanLock(changed));
|
||||
expect(() => verifyReleasePlanLock(redigested, params)).toThrow(
|
||||
"repository-derived authority",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -117,8 +117,8 @@ describe("full release validation evidence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([3, 4])("accepts canonical v%s SHA-pinned evidence bound to current main", (version) => {
|
||||
const { isTrustedMainAncestor, result } = validate({}, { version });
|
||||
it("accepts canonical SHA-pinned evidence bound to current main", () => {
|
||||
const { isTrustedMainAncestor, result } = validate();
|
||||
|
||||
expect(result.source).toBe("sha-pinned-main");
|
||||
expect(isTrustedMainAncestor).toHaveBeenCalledWith(workflowSha);
|
||||
@@ -257,7 +257,7 @@ describe("full release validation evidence", () => {
|
||||
["workflow full ref", {}, { workflowFullRef: "refs/heads/main" }, "workflowFullRef"],
|
||||
["target SHA", {}, { targetSha: "c".repeat(40) }, "targetSha"],
|
||||
["target ref", {}, { targetRef: "v2026.7.1-beta.3" }, "target ref"],
|
||||
["manifest version", {}, { version: 2 }, "version 3 or 4"],
|
||||
["manifest version", {}, { version: 2 }, "version 3"],
|
||||
])("rejects mismatched %s", (_name, runOverrides, manifestOverrides, message) => {
|
||||
expect(() => validate(runOverrides, manifestOverrides)).toThrow(message);
|
||||
});
|
||||
@@ -276,8 +276,8 @@ describe("full release validation evidence", () => {
|
||||
expect(() => validate({}, {}, false)).toThrow("not reachable from current main");
|
||||
});
|
||||
|
||||
it.each([3, 4])("accepts exact-target evidence reuse with a v%s manifest", (version) => {
|
||||
expect(validate({}, { version, evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe(
|
||||
it("accepts exact-target evidence reuse on the SHA-pinned path", () => {
|
||||
expect(validate({}, { evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe(
|
||||
"sha-pinned-main",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user