mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 03:45:46 -06:00
fix(release): define immutable release plan contract
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].includes(value.version) || value.workflowName !== "Full Release Validation") {
|
||||
if (![2, 3, 4].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 manifest");
|
||||
if (manifest.version < 3) {
|
||||
throw new Error("SHA-pinned release evidence requires a v3 or v4 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,15 +1231,16 @@ 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
|
||||
? "manifest-v3-protected-tag-exact-sha"
|
||||
? `${manifestName}-protected-tag-exact-sha`
|
||||
: shaPinned
|
||||
? "manifest-v3-sha-pinned-main-ancestry"
|
||||
: "manifest-v3-branch";
|
||||
? `${manifestName}-sha-pinned-main-ancestry`
|
||||
: `${manifestName}-branch`;
|
||||
}
|
||||
|
||||
if (!protectedTagRoute) {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
export type ReleasePlanPurpose =
|
||||
| "beta-publish"
|
||||
| "stable-publish"
|
||||
| "postpublish-confidence"
|
||||
| "main-qualification";
|
||||
|
||||
export type ReleasePlan = {
|
||||
schema: "openclaw.release-plan.v1";
|
||||
release_id: string;
|
||||
version: string;
|
||||
tag: string | null;
|
||||
candidate_sha: string;
|
||||
target_context_ref: string;
|
||||
purpose: ReleasePlanPurpose;
|
||||
tooling: {
|
||||
repository: "openclaw/openclaw";
|
||||
workflow_path: ".github/workflows/full-release-validation.yml";
|
||||
ref: string;
|
||||
sha: string;
|
||||
};
|
||||
validation: {
|
||||
profile: "beta" | "stable" | "full";
|
||||
soak: boolean;
|
||||
allowed_groups: string[];
|
||||
};
|
||||
inventory: {
|
||||
packages: Array<{ name: string; version: string; targets: string[] }>;
|
||||
platforms: Array<{ id: string; source: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ReleasePlanLock = {
|
||||
schema: "openclaw.release-plan-lock.v1";
|
||||
digest: string;
|
||||
plan: ReleasePlan;
|
||||
};
|
||||
|
||||
export const RELEASE_PLAN_SCHEMA: "openclaw.release-plan.v1";
|
||||
export const RELEASE_PLAN_LOCK_SCHEMA: "openclaw.release-plan-lock.v1";
|
||||
export const RELEASE_PLAN_CANONICALIZATION: "ascii-sorted-compact-json-trailing-newline-v1";
|
||||
export const RELEASE_PLAN_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;
|
||||
@@ -0,0 +1,337 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseDocument } from "yaml";
|
||||
import { isRecord } from "./lib/record-shared.mjs";
|
||||
|
||||
export const RELEASE_PLAN_SCHEMA = "openclaw.release-plan.v1";
|
||||
export const RELEASE_PLAN_LOCK_SCHEMA = "openclaw.release-plan-lock.v1";
|
||||
export const RELEASE_PLAN_CANONICALIZATION = "ascii-sorted-compact-json-trailing-newline-v1";
|
||||
export const RELEASE_PLAN_MAX_BYTES = 32 * 1024;
|
||||
|
||||
const SHA_PATTERN = /^[a-f0-9]{40}$/u;
|
||||
const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/u;
|
||||
const ASCII_PATTERN = /^[\x20-\x7e]+$/u;
|
||||
const REPOSITORY = "openclaw/openclaw";
|
||||
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
|
||||
const PURPOSES = new Set([
|
||||
"beta-publish",
|
||||
"stable-publish",
|
||||
"postpublish-confidence",
|
||||
"main-qualification",
|
||||
]);
|
||||
const PROFILES = new Set(["beta", "stable", "full"]);
|
||||
const PACKAGE_TARGETS = new Set(["clawhub", "npm"]);
|
||||
const compareAscii = (left, right) => (left < right ? -1 : left > right ? 1 : 0);
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
function exactKeys(value, keys, label) {
|
||||
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 asciiString(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 = asciiString(value, label);
|
||||
if (!SHA_PATTERN.test(normalized)) {
|
||||
fail(`${label} must be a lowercase 40-character commit SHA`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sortedUniqueStrings(value, label) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
fail(`${label} must be a non-empty array`);
|
||||
}
|
||||
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)
|
||||
) {
|
||||
fail(`${label} must contain unique strings in ascending ASCII order`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sortedUniqueEnumStrings(value, allowed, label) {
|
||||
const result = sortedUniqueStrings(value, label);
|
||||
const unsupported = result.find((entry) => !allowed.has(entry));
|
||||
if (unsupported) {
|
||||
fail(`${label} contains unsupported value: ${unsupported}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function canonicalize(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(canonicalize);
|
||||
}
|
||||
if (isRecord(value)) {
|
||||
return Object.fromEntries(
|
||||
Object.keys(value)
|
||||
.toSorted(compareAscii)
|
||||
.map((key) => [key, canonicalize(value[key])]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function canonicalAsciiJson(value) {
|
||||
const json = `${JSON.stringify(canonicalize(value))}\n`;
|
||||
if (!/^[\x20-\x7e]+\n$/u.test(json)) {
|
||||
fail("canonical JSON must be printable ASCII with exactly one trailing newline");
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
function validatePackages(value) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
fail("release plan packages must be a non-empty array");
|
||||
}
|
||||
const packages = value.map((entry, index) => {
|
||||
if (!isRecord(entry)) {
|
||||
fail(`release plan packages[${index}] must be an object`);
|
||||
}
|
||||
exactKeys(entry, ["name", "version", "targets"], `release plan packages[${index}]`);
|
||||
return {
|
||||
name: asciiString(entry.name, `release plan packages[${index}].name`),
|
||||
version: asciiString(entry.version, `release plan packages[${index}].version`),
|
||||
targets: sortedUniqueEnumStrings(
|
||||
entry.targets,
|
||||
PACKAGE_TARGETS,
|
||||
`release plan packages[${index}].targets`,
|
||||
),
|
||||
};
|
||||
});
|
||||
const names = packages.map((entry) => entry.name);
|
||||
if (
|
||||
new Set(names).size !== names.length ||
|
||||
names.some((entry, index) => index > 0 && names[index - 1] >= entry)
|
||||
) {
|
||||
fail("release plan packages must have unique names in ascending ASCII order");
|
||||
}
|
||||
return packages;
|
||||
}
|
||||
|
||||
function validatePlatforms(value) {
|
||||
if (!Array.isArray(value) || value.length === 0) {
|
||||
fail("release plan platforms must be a non-empty array");
|
||||
}
|
||||
const platforms = value.map((entry, index) => {
|
||||
if (!isRecord(entry)) {
|
||||
fail(`release plan platforms[${index}] must be an object`);
|
||||
}
|
||||
exactKeys(entry, ["id", "source"], `release plan platforms[${index}]`);
|
||||
return {
|
||||
id: asciiString(entry.id, `release plan platforms[${index}].id`),
|
||||
source: asciiString(entry.source, `release plan platforms[${index}].source`),
|
||||
};
|
||||
});
|
||||
const ids = platforms.map((entry) => entry.id);
|
||||
if (
|
||||
new Set(ids).size !== ids.length ||
|
||||
ids.some((entry, index) => index > 0 && ids[index - 1] >= entry)
|
||||
) {
|
||||
fail("release plan platforms must have unique ids in ascending ASCII order");
|
||||
}
|
||||
return platforms;
|
||||
}
|
||||
|
||||
export function validateReleasePlan(value) {
|
||||
if (!isRecord(value)) {
|
||||
fail("release plan must be an object");
|
||||
}
|
||||
exactKeys(
|
||||
value,
|
||||
[
|
||||
"schema",
|
||||
"release_id",
|
||||
"version",
|
||||
"tag",
|
||||
"candidate_sha",
|
||||
"target_context_ref",
|
||||
"purpose",
|
||||
"tooling",
|
||||
"validation",
|
||||
"inventory",
|
||||
],
|
||||
"release plan",
|
||||
);
|
||||
if (value.schema !== RELEASE_PLAN_SCHEMA) {
|
||||
fail(`release plan schema must be ${RELEASE_PLAN_SCHEMA}`);
|
||||
}
|
||||
const purpose = asciiString(value.purpose, "release plan purpose");
|
||||
if (!PURPOSES.has(purpose)) {
|
||||
fail(`unsupported release plan purpose: ${purpose}`);
|
||||
}
|
||||
const version = asciiString(value.version, "release plan version");
|
||||
const releaseId = asciiString(value.release_id, "release plan release_id");
|
||||
if (releaseId !== version) {
|
||||
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");
|
||||
}
|
||||
const expectedPolicy = {
|
||||
"beta-publish": { profile: "beta", soak: false },
|
||||
"stable-publish": { profile: "stable", soak: true },
|
||||
"postpublish-confidence": { profile: "full", soak: true },
|
||||
"main-qualification": { profile: "full", soak: true },
|
||||
}[purpose];
|
||||
if (!isRecord(value.tooling)) {
|
||||
fail("release plan tooling must be an object");
|
||||
}
|
||||
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)) {
|
||||
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");
|
||||
const profile = asciiString(value.validation.profile, "release plan validation profile");
|
||||
if (!PROFILES.has(profile)) {
|
||||
fail(`unsupported release plan validation profile: ${profile}`);
|
||||
}
|
||||
if (typeof value.validation.soak !== "boolean") {
|
||||
fail("release plan validation soak must be boolean");
|
||||
}
|
||||
if (profile !== expectedPolicy.profile || value.validation.soak !== expectedPolicy.soak) {
|
||||
fail(`release plan ${purpose} validation policy is invalid`);
|
||||
}
|
||||
if (!isRecord(value.inventory)) {
|
||||
fail("release plan inventory must be an object");
|
||||
}
|
||||
exactKeys(value.inventory, ["packages", "platforms"], "release plan inventory");
|
||||
const plan = {
|
||||
schema: RELEASE_PLAN_SCHEMA,
|
||||
release_id: releaseId,
|
||||
version,
|
||||
tag,
|
||||
candidate_sha: candidateSha,
|
||||
target_context_ref: targetContextRef,
|
||||
purpose,
|
||||
tooling: {
|
||||
repository: asciiString(value.tooling.repository, "release plan tooling repository"),
|
||||
workflow_path: asciiString(value.tooling.workflow_path, "release plan tooling workflow_path"),
|
||||
ref: toolingRef,
|
||||
sha: sha(value.tooling.sha, "release plan tooling SHA"),
|
||||
},
|
||||
validation: {
|
||||
profile,
|
||||
soak: value.validation.soak,
|
||||
allowed_groups: sortedUniqueStrings(
|
||||
value.validation.allowed_groups,
|
||||
"release plan validation allowed_groups",
|
||||
),
|
||||
},
|
||||
inventory: {
|
||||
packages: validatePackages(value.inventory.packages),
|
||||
platforms: validatePlatforms(value.inventory.platforms),
|
||||
},
|
||||
};
|
||||
if (plan.tooling.repository !== REPOSITORY) {
|
||||
fail(`release plan tooling repository must be ${REPOSITORY}`);
|
||||
}
|
||||
if (plan.tooling.workflow_path !== WORKFLOW_PATH) {
|
||||
fail(`release plan tooling workflow_path must be ${WORKFLOW_PATH}`);
|
||||
}
|
||||
if (Buffer.byteLength(canonicalAsciiJson(plan), "ascii") > RELEASE_PLAN_MAX_BYTES) {
|
||||
fail(`release plan exceeds ${RELEASE_PLAN_MAX_BYTES} bytes`);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
export function canonicalReleasePlanJson(value) {
|
||||
return canonicalAsciiJson(validateReleasePlan(value));
|
||||
}
|
||||
|
||||
export function releasePlanDigest(value) {
|
||||
return `sha256:${createHash("sha256").update(canonicalReleasePlanJson(value), "ascii").digest("hex")}`;
|
||||
}
|
||||
|
||||
export function createReleasePlanLock(value) {
|
||||
const plan = validateReleasePlan(value);
|
||||
return {
|
||||
schema: RELEASE_PLAN_LOCK_SCHEMA,
|
||||
digest: releasePlanDigest(plan),
|
||||
plan,
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
exactKeys(value, ["schema", "digest", "plan"], "release plan lock");
|
||||
if (value.schema !== RELEASE_PLAN_LOCK_SCHEMA) {
|
||||
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)) {
|
||||
fail("release plan lock digest does not match its canonical plan");
|
||||
}
|
||||
return { schema: RELEASE_PLAN_LOCK_SCHEMA, digest, plan };
|
||||
}
|
||||
|
||||
export function canonicalReleasePlanLockJson(value) {
|
||||
return canonicalAsciiJson(validateReleasePlanLock(value));
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
const document = parseDocument(text, { strict: true, uniqueKeys: true });
|
||||
if (document.errors.length > 0) {
|
||||
const duplicate = document.errors.find((error) =>
|
||||
error.message.includes("keys must be unique"),
|
||||
);
|
||||
fail(
|
||||
duplicate
|
||||
? "release plan JSON contains a duplicate key"
|
||||
: `release plan lock JSON is invalid: ${document.errors[0].message}`,
|
||||
);
|
||||
}
|
||||
return validateReleasePlanLock(JSON.parse(text));
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { 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 {
|
||||
canonicalReleasePlanLockJson,
|
||||
createReleasePlanLock,
|
||||
RELEASE_PLAN_SCHEMA,
|
||||
validateReleasePlan,
|
||||
type ReleasePlan,
|
||||
type ReleasePlanPurpose,
|
||||
} from "./release-plan-contract.mjs";
|
||||
|
||||
type ReleasePlanIntent = "publish" | "postpublish-confidence" | "main-qualification";
|
||||
|
||||
const REPOSITORY = "openclaw/openclaw";
|
||||
const WORKFLOW_PATH = ".github/workflows/full-release-validation.yml";
|
||||
const INVENTORY_PATH = "scripts/release-plan-inventory.json";
|
||||
|
||||
function readJson(path: string): unknown {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
export function deriveReleasePlanPolicy(
|
||||
intent: ReleasePlanIntent,
|
||||
version: string,
|
||||
): {
|
||||
profile: "beta" | "stable" | "full";
|
||||
purpose: ReleasePlanPurpose;
|
||||
soak: boolean;
|
||||
tag: string | null;
|
||||
} {
|
||||
const prerelease = /-(?:alpha|beta)\.[1-9][0-9]*$/u.test(version);
|
||||
if (intent === "main-qualification") {
|
||||
return { profile: "full", purpose: "main-qualification", soak: true, tag: null };
|
||||
}
|
||||
if (intent === "postpublish-confidence") {
|
||||
return {
|
||||
profile: "full",
|
||||
purpose: "postpublish-confidence",
|
||||
soak: true,
|
||||
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}` };
|
||||
}
|
||||
|
||||
function collectAllowedGroups(repoRoot: string): string[] {
|
||||
const workflow = parseYaml(readFileSync(resolve(repoRoot, WORKFLOW_PATH), "utf8")) as {
|
||||
on?: { workflow_dispatch?: { inputs?: { rerun_group?: { options?: unknown } } } };
|
||||
};
|
||||
const options = workflow.on?.workflow_dispatch?.inputs?.rerun_group?.options;
|
||||
if (
|
||||
!Array.isArray(options) ||
|
||||
options.length === 0 ||
|
||||
options.some((entry) => typeof entry !== "string" || !entry)
|
||||
) {
|
||||
throw new Error(`${WORKFLOW_PATH} must declare rerun_group choice options`);
|
||||
}
|
||||
return [...new Set(options)].toSorted((left, right) =>
|
||||
left < right ? -1 : left > right ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
function collectPackageInventory(repoRoot: 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)) {
|
||||
packages.set(plugin.packageName, {
|
||||
name: plugin.packageName,
|
||||
version: plugin.version,
|
||||
targets: new Set(["npm"]),
|
||||
});
|
||||
}
|
||||
for (const plugin of collectClawHubPublishablePluginPackages(repoRoot)) {
|
||||
const existing = packages.get(plugin.packageName);
|
||||
if (existing && existing.version !== plugin.version) {
|
||||
throw new Error(`plugin inventory version mismatch for ${plugin.packageName}`);
|
||||
}
|
||||
const entry = existing ?? {
|
||||
name: plugin.packageName,
|
||||
version: plugin.version,
|
||||
targets: new Set<string>(),
|
||||
};
|
||||
entry.targets.add("clawhub");
|
||||
packages.set(plugin.packageName, entry);
|
||||
}
|
||||
return [...packages.values()]
|
||||
.map((entry) => ({
|
||||
name: entry.name,
|
||||
version: entry.version,
|
||||
targets: [...entry.targets].toSorted(),
|
||||
}))
|
||||
.toSorted((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
function collectPlatformInventory(repoRoot: string) {
|
||||
const inventory = readJson(resolve(repoRoot, INVENTORY_PATH)) as {
|
||||
schema?: unknown;
|
||||
platforms?: 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`);
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
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",
|
||||
});
|
||||
if (worktreeStatus) {
|
||||
throw new Error("release plan source checkout must be clean");
|
||||
}
|
||||
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");
|
||||
}
|
||||
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");
|
||||
}
|
||||
const policy = deriveReleasePlanPolicy(params.intent, rootPackage.version);
|
||||
return validateReleasePlan({
|
||||
schema: RELEASE_PLAN_SCHEMA,
|
||||
release_id: rootPackage.version,
|
||||
version: rootPackage.version,
|
||||
tag: policy.tag,
|
||||
candidate_sha: candidateSha,
|
||||
target_context_ref: policy.tag ? `refs/tags/${policy.tag}` : candidateSha,
|
||||
purpose: policy.purpose,
|
||||
tooling: {
|
||||
repository: REPOSITORY,
|
||||
workflow_path: WORKFLOW_PATH,
|
||||
ref: params.toolingFullRef,
|
||||
sha: toolingSha,
|
||||
},
|
||||
validation: {
|
||||
profile: policy.profile,
|
||||
soak: policy.soak,
|
||||
allowed_groups: collectAllowedGroups(repoRoot),
|
||||
},
|
||||
inventory: {
|
||||
packages: collectPackageInventory(repoRoot, rootPackage.version),
|
||||
platforms: collectPlatformInventory(repoRoot),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function requiredOption(args: string[], name: string): string {
|
||||
const index = args.indexOf(name);
|
||||
const value = index >= 0 ? args[index + 1] : undefined;
|
||||
if (!value || value.startsWith("-")) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const intent = requiredOption(args, "--intent") as ReleasePlanIntent;
|
||||
if (!["publish", "postpublish-confidence", "main-qualification"].includes(intent)) {
|
||||
throw new Error("--intent must be publish, postpublish-confidence, or main-qualification");
|
||||
}
|
||||
const plan = produceReleasePlan({
|
||||
intent,
|
||||
toolingFullRef: requiredOption(args, "--tooling-full-ref"),
|
||||
});
|
||||
process.stdout.write(canonicalReleasePlanLockJson(createReleasePlanLock(plan)));
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
console.error("[release-plan-producer] FAILED (exit 1)");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -172,9 +172,9 @@ export function validateFullReleaseValidationEvidence({
|
||||
);
|
||||
}
|
||||
|
||||
if (manifest.version !== 3) {
|
||||
if (![3, 4].includes(manifest.version)) {
|
||||
throw new Error(
|
||||
`Full release validation manifest must use version 3, got ${displayValue(manifest.version)}.`,
|
||||
`Full release validation manifest must use version 3 or 4, got ${displayValue(manifest.version)}.`,
|
||||
);
|
||||
}
|
||||
const manifestChecks = [
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -420,7 +420,7 @@ function rawManifest({
|
||||
rerunGroup?: string;
|
||||
runId?: string;
|
||||
targetSha?: string;
|
||||
version?: 2 | 3;
|
||||
version?: 2 | 3 | 4;
|
||||
workflowFullRef?: string;
|
||||
workflowRefType?: "branch" | "tag";
|
||||
workflowSha?: string;
|
||||
@@ -436,7 +436,7 @@ function rawManifest({
|
||||
targetRef?: string;
|
||||
targetSha: string;
|
||||
validationInputs: Record<string, string>;
|
||||
version: 2 | 3;
|
||||
version: 2 | 3 | 4;
|
||||
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;
|
||||
manifestVersion?: 2 | 3 | 4;
|
||||
parentPath?: string;
|
||||
targetSha?: string;
|
||||
workflowFullRef?: string;
|
||||
@@ -1327,39 +1327,42 @@ describe("release CI summary child correlation", () => {
|
||||
},
|
||||
);
|
||||
|
||||
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",
|
||||
};
|
||||
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",
|
||||
};
|
||||
|
||||
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",
|
||||
});
|
||||
});
|
||||
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`,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a SHA-pinned evidenceReuse field even when false", () => {
|
||||
const workflowSha = "7".repeat(40);
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
canonicalReleasePlanJson,
|
||||
canonicalReleasePlanLockJson,
|
||||
createReleasePlanLock,
|
||||
parseReleasePlanLockJson,
|
||||
RELEASE_PLAN_CANONICALIZATION,
|
||||
releasePlanDigest,
|
||||
validateReleasePlan,
|
||||
validateReleasePlanLock,
|
||||
} 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>;
|
||||
|
||||
describe("release plan contract", () => {
|
||||
it("matches the exact public and private-consumer compatibility fixture", () => {
|
||||
const canonical = canonicalReleasePlanJson(sourceFixture);
|
||||
|
||||
expect(RELEASE_PLAN_CANONICALIZATION).toBe("ascii-sorted-compact-json-trailing-newline-v1");
|
||||
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(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(
|
||||
canonicalLock.replace('{"digest":', `{"digest":"${String(lockFixture.digest)}","digest":`),
|
||||
),
|
||||
).toThrow("duplicate key");
|
||||
expect(() => validateReleasePlan({ ...sourceFixture, run_id: "123" })).toThrow(
|
||||
"release plan keys must be exactly",
|
||||
);
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
release_id: "2026.8.1-béta.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", () => {
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
inventory: {
|
||||
...(sourceFixture.inventory as Record<string, unknown>),
|
||||
packages: [
|
||||
{
|
||||
name: "openclaw",
|
||||
version: "2026.8.1-beta.2",
|
||||
targets: ["npm"],
|
||||
},
|
||||
{
|
||||
name: "@openclaw/example",
|
||||
version: "2026.8.1-beta.2",
|
||||
targets: ["clawhub", "npm"],
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).toThrow("packages must have unique names in ascending ASCII order");
|
||||
expect(() =>
|
||||
validateReleasePlan({
|
||||
...sourceFixture,
|
||||
validation: {
|
||||
allowed_groups: ["all"],
|
||||
profile: "full",
|
||||
soak: true,
|
||||
},
|
||||
}),
|
||||
).toThrow("beta-publish validation policy is invalid");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
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 { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
deriveReleasePlanPolicy,
|
||||
produceReleasePlan,
|
||||
} from "../../scripts/release-plan-producer.mts";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
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" }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(root, ".github/workflows/full-release-validation.yml"),
|
||||
[
|
||||
"on:",
|
||||
" workflow_dispatch:",
|
||||
" inputs:",
|
||||
" rerun_group:",
|
||||
" options:",
|
||||
" - package",
|
||||
" - all",
|
||||
" - ci",
|
||||
"",
|
||||
].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",
|
||||
[
|
||||
"-c",
|
||||
"user.name=OpenClaw Test",
|
||||
"-c",
|
||||
"user.email=test@example.invalid",
|
||||
"commit",
|
||||
"-q",
|
||||
"-m",
|
||||
"fixture",
|
||||
],
|
||||
{ cwd: root },
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("release plan producer", () => {
|
||||
it("derives purpose, profile, tag, and soak from intent and version", () => {
|
||||
expect(deriveReleasePlanPolicy("publish", "2026.8.1-beta.2")).toEqual({
|
||||
profile: "beta",
|
||||
purpose: "beta-publish",
|
||||
soak: false,
|
||||
tag: "v2026.8.1-beta.2",
|
||||
});
|
||||
expect(deriveReleasePlanPolicy("publish", "2026.8.1")).toEqual({
|
||||
profile: "stable",
|
||||
purpose: "stable-publish",
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
|
||||
expect(plan).toMatchObject({
|
||||
candidate_sha: sha,
|
||||
purpose: "main-qualification",
|
||||
release_id: "2026.8.1",
|
||||
tag: null,
|
||||
target_context_ref: sha,
|
||||
version: "2026.8.1",
|
||||
});
|
||||
expect(plan.tooling).toEqual({
|
||||
ref: "refs/heads/tooling",
|
||||
repository: "openclaw/openclaw",
|
||||
sha,
|
||||
workflow_path: ".github/workflows/full-release-validation.yml",
|
||||
});
|
||||
expect(plan.validation.allowed_groups).toEqual(["all", "ci", "package"]);
|
||||
expect(plan.inventory.packages).toEqual([
|
||||
{ name: "openclaw", targets: ["npm"], version: "2026.8.1" },
|
||||
]);
|
||||
expect(plan.inventory.platforms.map((platform) => platform.id)).toEqual([
|
||||
"android",
|
||||
"docker",
|
||||
"windows",
|
||||
]);
|
||||
});
|
||||
|
||||
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"}');
|
||||
|
||||
expect(() =>
|
||||
produceReleasePlan({
|
||||
repoRoot,
|
||||
intent: "publish",
|
||||
toolingFullRef: "refs/heads/tooling",
|
||||
}),
|
||||
).toThrow("source checkout must be clean");
|
||||
});
|
||||
});
|
||||
@@ -117,8 +117,8 @@ describe("full release validation evidence", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts canonical SHA-pinned evidence bound to current main", () => {
|
||||
const { isTrustedMainAncestor, result } = validate();
|
||||
it.each([3, 4])("accepts canonical v%s SHA-pinned evidence bound to current main", (version) => {
|
||||
const { isTrustedMainAncestor, result } = validate({}, { version });
|
||||
|
||||
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"],
|
||||
["manifest version", {}, { version: 2 }, "version 3 or 4"],
|
||||
])("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("accepts exact-target evidence reuse on the SHA-pinned path", () => {
|
||||
expect(validate({}, { evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe(
|
||||
it.each([3, 4])("accepts exact-target evidence reuse with a v%s manifest", (version) => {
|
||||
expect(validate({}, { version, evidenceReuse: exactTargetEvidenceReuse() }).result.source).toBe(
|
||||
"sha-pinned-main",
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user