mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 04:15:48 -06:00
1da74794b6
* fix(release): define immutable release plan contract * fix(release): harden immutable plan authority * fix(release): tighten release plan authority * fix(release): share plugin publication authority * fix(release): verify plan authority remotely * fix(release): track ClawHub publication authorities * fix(release): trust remote tooling tag identity * fix(release): close plugin publication authority * fix(release): align npm authority selection * fix(release): bind plans to validation intent * fix(release): require qualification cadence * fix(release): reject lossy canonical values * fix(release): narrow qualification cadence * fix(release): bind plan parser dependency * fix(release): add tagless diagnostic plans * fix(release): attest release plan parser tree * fix(release): isolate verified plan parser snapshot * fix(release): verify plan tooling before execution
1202 lines
42 KiB
TypeScript
1202 lines
42 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import {
|
|
cpSync,
|
|
existsSync,
|
|
linkSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
readdirSync,
|
|
realpathSync,
|
|
rmSync,
|
|
statSync,
|
|
symlinkSync,
|
|
unlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { parse } from "yaml";
|
|
import { collectClawHubPublishablePluginPackages } from "../../scripts/lib/plugin-clawhub-release.ts";
|
|
import { collectPublishablePluginPackages } from "../../scripts/lib/plugin-npm-release.ts";
|
|
import {
|
|
canonicalReleasePlanLockJson,
|
|
createReleasePlanLock,
|
|
type ReleasePlan,
|
|
type ReleasePlanLock,
|
|
} from "../../scripts/release-plan-contract.mjs";
|
|
import {
|
|
deriveReleasePlanPolicy,
|
|
runReleasePlanProducerOperation,
|
|
} from "../../scripts/release-plan-producer-core.mts";
|
|
import {
|
|
produceReleasePlan as trustedCheckoutProduceReleasePlan,
|
|
verifyReleasePlanLock as trustedCheckoutVerifyReleasePlanLock,
|
|
type MainQualificationValidationIntent,
|
|
type ReleasePlanIntent,
|
|
type ReleasePlanSource,
|
|
} from "../../scripts/release-plan-producer.mts";
|
|
import { writePublishablePluginFixture } from "../helpers/publishable-plugin-fixture.js";
|
|
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
|
|
|
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
|
const TOOLING_CLOSURE = [
|
|
"packages/normalization-core/src/record-coerce.ts",
|
|
"packages/normalization-core/src/string-coerce.ts",
|
|
"packages/plugin-package-contract/src/index.ts",
|
|
"scripts/release-plan-producer.mts",
|
|
"scripts/release-plan-producer-core.mts",
|
|
"scripts/release-plan-contract.mjs",
|
|
"scripts/release-validation-intent.mjs",
|
|
"scripts/release-tooling-identity.mjs",
|
|
"scripts/lib/npm-publish-plan.mjs",
|
|
"scripts/lib/plugin-publication-candidates.ts",
|
|
"scripts/lib/plugin-publication-collector.ts",
|
|
"scripts/lib/record-shared.mjs",
|
|
"scripts/lib/release-version.mjs",
|
|
];
|
|
const TOOLING_ROOT_FILES = ["package.json", "pnpm-lock.yaml"];
|
|
|
|
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, options: { allowEmpty?: boolean } = {}): string {
|
|
execFileSync("git", ["add", "."], { cwd: root });
|
|
execFileSync(
|
|
"git",
|
|
[
|
|
"-c",
|
|
"user.name=OpenClaw Test",
|
|
"-c",
|
|
"user.email=test@example.invalid",
|
|
"commit",
|
|
"-q",
|
|
...(options.allowEmpty ? ["--allow-empty"] : []),
|
|
"-m",
|
|
message,
|
|
],
|
|
{ cwd: root },
|
|
);
|
|
return execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim();
|
|
}
|
|
|
|
function copyToolingClosure(root: string) {
|
|
for (const path of [...TOOLING_CLOSURE, ...TOOLING_ROOT_FILES]) {
|
|
writeFixture(root, path, readFileSync(resolve(path), "utf8"));
|
|
}
|
|
}
|
|
|
|
function createFixtureRepo(
|
|
version = "2026.8.1-beta.2",
|
|
options: {
|
|
conflictingPlatformId?: boolean;
|
|
corePackageNameCollision?: boolean;
|
|
duplicateCrossTargetPackageName?: boolean;
|
|
malformedPlugin?: boolean;
|
|
malformedPluginJson?: boolean;
|
|
} = {},
|
|
) {
|
|
const root = tempDirs.make("openclaw-release-plan-");
|
|
execFileSync("git", ["init", "-q", "-b", "tooling"], { cwd: root });
|
|
|
|
writeFixture(
|
|
root,
|
|
"package.json",
|
|
JSON.stringify({
|
|
name: "openclaw",
|
|
version,
|
|
dependencies: { "@openclaw/ai": "workspace:*" },
|
|
}),
|
|
);
|
|
for (const [path, name] of [
|
|
["packages/ai", "@openclaw/ai"],
|
|
["packages/gateway-client", "@openclaw/gateway-client"],
|
|
["packages/gateway-protocol", "@openclaw/gateway-protocol"],
|
|
]) {
|
|
writeFixture(
|
|
root,
|
|
`${path}/package.json`,
|
|
JSON.stringify({
|
|
name,
|
|
version,
|
|
openclaw: { release: { publishToNpm: true } },
|
|
}),
|
|
);
|
|
}
|
|
if (options.corePackageNameCollision) {
|
|
writePublishablePluginFixture(root, {
|
|
extensionId: "shadow-ai",
|
|
packageName: "@openclaw/ai",
|
|
version,
|
|
publishTo: "both",
|
|
});
|
|
} else if (options.duplicateCrossTargetPackageName) {
|
|
writePublishablePluginFixture(root, {
|
|
extensionId: "duplicate-npm",
|
|
packageName: "@openclaw/duplicate",
|
|
version,
|
|
publishTo: "npm",
|
|
});
|
|
writePublishablePluginFixture(root, {
|
|
extensionId: "duplicate-clawhub",
|
|
packageName: "@openclaw/duplicate",
|
|
version,
|
|
publishTo: "clawhub",
|
|
});
|
|
} else if (options.malformedPluginJson) {
|
|
writeFixture(root, "extensions/broken/package.json", "{ not-json\n");
|
|
} else if (options.malformedPlugin) {
|
|
writeFixture(
|
|
root,
|
|
"extensions/broken/package.json",
|
|
JSON.stringify({
|
|
name: "@openclaw/broken",
|
|
version,
|
|
type: "commonjs",
|
|
private: true,
|
|
repository: { type: "git", url: "https://github.com/openclaw/openclaw" },
|
|
openclaw: {
|
|
extensions: ["./index.ts"],
|
|
compat: { pluginApi: `>=${version}` },
|
|
build: { openclawVersion: version },
|
|
install: { npmSpec: "@openclaw/broken" },
|
|
release: { publishToNpm: true },
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
const candidateSha = commit(root, "candidate");
|
|
const candidateRef = `refs/tags/v${version}`;
|
|
|
|
writeFixture(
|
|
root,
|
|
".github/workflows/full-release-validation.yml",
|
|
[
|
|
"on:",
|
|
" workflow_dispatch:",
|
|
" inputs:",
|
|
" rerun_group:",
|
|
" options:",
|
|
" - package",
|
|
" - all",
|
|
" - ci",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
writeFixture(
|
|
root,
|
|
".github/workflows/openclaw-release-publish.yml",
|
|
[
|
|
"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",
|
|
...(options.conflictingPlatformId
|
|
? [" publish_windows:", " uses: ./.github/workflows/docker-release.yml"]
|
|
: []),
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
writeFixture(
|
|
root,
|
|
".github/workflows/openclaw-npm-release.yml",
|
|
[
|
|
"name: NPM Release",
|
|
"jobs:",
|
|
" preflight:",
|
|
" steps:",
|
|
" - name: Pack publishable core packages",
|
|
" env:",
|
|
" CORE_PACKAGE_DIRS: packages/ai packages/gateway-protocol packages/gateway-client",
|
|
" run: |",
|
|
' if [[ "$package_dir" == "packages/ai" ]] && ! node -e \'const pkg = require("./package.json"); process.exit(pkg.dependencies?.["@openclaw/ai"] ? 0 : 1)\'; then',
|
|
" exit 0",
|
|
" fi",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
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`);
|
|
}
|
|
copyToolingClosure(root);
|
|
const toolingSha = commit(root, "tooling");
|
|
const toolingFullRef = `refs/tags/release-publish/${toolingSha.slice(0, 12)}-1`;
|
|
return { candidateRef, candidateSha, root, toolingFullRef, toolingSha };
|
|
}
|
|
|
|
function sourceParams(
|
|
fixture: ReturnType<typeof createFixtureRepo>,
|
|
intent: ReleasePlanIntent = "publish",
|
|
validationIntent?: MainQualificationValidationIntent,
|
|
): ReleasePlanSource {
|
|
const source = {
|
|
repoRoot: fixture.root,
|
|
candidateSha: fixture.candidateSha,
|
|
candidateRef:
|
|
intent === "diagnostic" || intent === "main-qualification"
|
|
? fixture.candidateSha
|
|
: fixture.candidateRef,
|
|
toolingSha: fixture.toolingSha,
|
|
toolingFullRef: fixture.toolingFullRef,
|
|
runGh: trustedToolingGh(fixture.toolingFullRef, fixture.toolingSha),
|
|
};
|
|
return (
|
|
intent === "main-qualification"
|
|
? { ...source, intent, validationIntent }
|
|
: { ...source, intent }
|
|
) as ReleasePlanSource;
|
|
}
|
|
|
|
function trustedToolingGh(toolingFullRef: string, toolingSha: string) {
|
|
return (args: string[]) => {
|
|
const endpoint = args[1];
|
|
if (
|
|
endpoint ===
|
|
`repos/openclaw/openclaw/git/ref/tags/${toolingFullRef.slice("refs/tags/".length)}`
|
|
) {
|
|
return JSON.stringify({
|
|
ref: toolingFullRef,
|
|
object: { type: "commit", sha: toolingSha },
|
|
});
|
|
}
|
|
throw new Error(`unexpected GitHub API request: ${args.join(" ")}`);
|
|
};
|
|
}
|
|
|
|
function runCoreOperation(
|
|
request:
|
|
| { operation: "produce"; params: ReleasePlanSource }
|
|
| { operation: "verify-lock"; lockJson: string; params: ReleasePlanSource },
|
|
) {
|
|
const { runGh, ...params } = request.params;
|
|
return runReleasePlanProducerOperation(
|
|
{ ...request, params },
|
|
{
|
|
runGh:
|
|
runGh ??
|
|
(() => {
|
|
throw new Error("unexpected GitHub API request");
|
|
}),
|
|
parseYamlDocuments: (sources) =>
|
|
sources.map((source) => parse(source)) as [unknown, unknown, unknown],
|
|
},
|
|
);
|
|
}
|
|
|
|
function produceReleasePlan(params: ReleasePlanSource) {
|
|
return runCoreOperation({ operation: "produce", params }) as ReleasePlan;
|
|
}
|
|
|
|
function verifyReleasePlanLock(lockJson: string, params: ReleasePlanSource) {
|
|
return runCoreOperation({ operation: "verify-lock", lockJson, params }) as ReleasePlanLock;
|
|
}
|
|
|
|
type YamlPackageHarnessParams = {
|
|
fixture: ReturnType<typeof createFixtureRepo>;
|
|
packageRoot: string;
|
|
sentinelPath: string;
|
|
tempRoot: string;
|
|
};
|
|
|
|
function runYamlPackageSubprocess(
|
|
options: {
|
|
beforeProduce?: (params: YamlPackageHarnessParams) => string;
|
|
environment?: (params: YamlPackageHarnessParams) => Record<string, string>;
|
|
mutate?: (params: YamlPackageHarnessParams) => void;
|
|
mutateTooling?: (fixture: ReturnType<typeof createFixtureRepo>) => void;
|
|
beforeImport?: (params: YamlPackageHarnessParams) => string;
|
|
executionHead?: (fixture: ReturnType<typeof createFixtureRepo>) => string;
|
|
remoteToolingSha?: (params: YamlPackageHarnessParams) => string;
|
|
} = {},
|
|
) {
|
|
let fixture = createFixtureRepo();
|
|
if (options.mutateTooling) {
|
|
options.mutateTooling(fixture);
|
|
const toolingSha = commit(fixture.root, "mutated tooling");
|
|
fixture = {
|
|
...fixture,
|
|
toolingSha,
|
|
toolingFullRef: `refs/tags/release-publish/${toolingSha.slice(0, 12)}-2`,
|
|
};
|
|
}
|
|
const packageRoot = join(fixture.root, "node_modules/yaml");
|
|
cpSync(realpathSync(resolve("node_modules/yaml")), packageRoot, { recursive: true });
|
|
const sentinelPath = join(fixture.root, "yaml-executed");
|
|
const tempRoot = join(fixture.root, "yaml-temp");
|
|
mkdirSync(tempRoot);
|
|
const params = { fixture, packageRoot, sentinelPath, tempRoot };
|
|
options.mutate?.(params);
|
|
writeFixture(
|
|
fixture.root,
|
|
"yaml-package-harness.mts",
|
|
`
|
|
${options.beforeImport?.(params) ?? ""}
|
|
const { produceReleasePlan } = await import("./scripts/release-plan-producer.mts");
|
|
${options.beforeProduce?.(params) ?? ""}
|
|
|
|
const toolingFullRef = ${JSON.stringify(fixture.toolingFullRef)};
|
|
const toolingSha = ${JSON.stringify(fixture.toolingSha)};
|
|
produceReleasePlan({
|
|
repoRoot: ${JSON.stringify(fixture.root)},
|
|
intent: "publish",
|
|
candidateSha: ${JSON.stringify(fixture.candidateSha)},
|
|
candidateRef: ${JSON.stringify(fixture.candidateRef)},
|
|
toolingSha,
|
|
toolingFullRef,
|
|
runGh: () => JSON.stringify({
|
|
ref: toolingFullRef,
|
|
object: { type: "commit", sha: ${JSON.stringify(options.remoteToolingSha?.(params) ?? fixture.toolingSha)} },
|
|
}),
|
|
});
|
|
const moduleApi = await import("node:module");
|
|
const harnessRequire = moduleApi.createRequire(import.meta.url);
|
|
const leakedSnapshotCache = Object.keys(harnessRequire.cache).filter(path =>
|
|
path.includes("openclaw-release-yaml-")
|
|
);
|
|
if (leakedSnapshotCache.length > 0) {
|
|
throw new Error("verified yaml snapshot leaked into parent require.cache");
|
|
}
|
|
`,
|
|
);
|
|
if (options.executionHead) {
|
|
execFileSync("git", ["update-ref", "HEAD", options.executionHead(fixture)], {
|
|
cwd: fixture.root,
|
|
});
|
|
}
|
|
return {
|
|
result: spawnSync(process.execPath, [join(fixture.root, "yaml-package-harness.mts")], {
|
|
cwd: fixture.root,
|
|
encoding: "utf8",
|
|
env: {
|
|
...process.env,
|
|
TMPDIR: tempRoot,
|
|
...options.environment?.(params),
|
|
},
|
|
}),
|
|
fixture,
|
|
packageRoot,
|
|
sentinelPath,
|
|
tempRoot,
|
|
};
|
|
}
|
|
|
|
function yamlTempEntries(root: string) {
|
|
return readdirSync(root).filter((name) => name.startsWith("openclaw-release-yaml-"));
|
|
}
|
|
|
|
describe("release plan producer", () => {
|
|
it("rejects invalid identity through the verified child dispatcher", () => {
|
|
expect(() =>
|
|
runReleasePlanProducerOperation(
|
|
{
|
|
operation: "produce",
|
|
params: {
|
|
candidateRef: "refs/tags/v2026.8.1-beta.2",
|
|
candidateSha: "not-a-sha",
|
|
intent: "publish",
|
|
toolingFullRef: "refs/heads/main",
|
|
toolingSha: "f".repeat(40),
|
|
},
|
|
},
|
|
{
|
|
parseYamlDocuments: () => [{}, {}, {}],
|
|
runGh: () => {
|
|
throw new Error("unexpected GitHub request");
|
|
},
|
|
},
|
|
),
|
|
).toThrow("candidate SHA must be an exact lowercase 40-character commit SHA");
|
|
});
|
|
|
|
it("derives purpose, profile, tag, and soak from the canonical version parser", () => {
|
|
expect(deriveReleasePlanPolicy("publish", "2026.8.1-beta.2")).toEqual({
|
|
intent: "release-beta",
|
|
profile: "beta",
|
|
publishable: true,
|
|
purpose: "beta-publish",
|
|
soak: false,
|
|
tag: "v2026.8.1-beta.2",
|
|
});
|
|
expect(deriveReleasePlanPolicy("publish", "2026.8.1")).toEqual({
|
|
intent: "release-stable",
|
|
profile: "stable",
|
|
publishable: true,
|
|
purpose: "stable-publish",
|
|
soak: true,
|
|
tag: "v2026.8.1",
|
|
});
|
|
expect(deriveReleasePlanPolicy("postpublish-confidence", "2026.8.1-beta.2")).toEqual({
|
|
intent: "diagnostic-full",
|
|
profile: "full",
|
|
publishable: false,
|
|
purpose: "postpublish-confidence",
|
|
soak: true,
|
|
tag: "v2026.8.1-beta.2",
|
|
});
|
|
expect(deriveReleasePlanPolicy("diagnostic", "2026.8.1-beta.2")).toEqual({
|
|
intent: "diagnostic-full",
|
|
profile: "full",
|
|
publishable: false,
|
|
purpose: "diagnostic",
|
|
soak: true,
|
|
tag: null,
|
|
});
|
|
expect(() => deriveReleasePlanPolicy("main-qualification", "2026.8.1-beta.2")).toThrow(
|
|
"requires an explicit validation intent",
|
|
);
|
|
expect(deriveReleasePlanPolicy("main-qualification", "2026.8.1-beta.2", "main-daily")).toEqual({
|
|
intent: "main-daily",
|
|
profile: "beta",
|
|
publishable: false,
|
|
purpose: "main-qualification",
|
|
soak: false,
|
|
tag: null,
|
|
});
|
|
expect(deriveReleasePlanPolicy("main-qualification", "2026.8.1-beta.2", "main-weekly")).toEqual(
|
|
{
|
|
intent: "main-weekly",
|
|
profile: "full",
|
|
publishable: false,
|
|
purpose: "main-qualification",
|
|
soak: true,
|
|
tag: null,
|
|
},
|
|
);
|
|
expect(() => deriveReleasePlanPolicy("publish", "2026.08.1")).toThrow(
|
|
"unsupported release version",
|
|
);
|
|
expect(() =>
|
|
deriveReleasePlanPolicy("unexpected" as ReleasePlanIntent, "2026.8.1-beta.2"),
|
|
).toThrow("unsupported release plan intent");
|
|
});
|
|
|
|
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.candidateRef], {
|
|
cwd: fixture.root,
|
|
stdio: "ignore",
|
|
}),
|
|
).toThrow();
|
|
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();
|
|
expect(() =>
|
|
execFileSync("git", ["cat-file", "-e", fixture.toolingFullRef], {
|
|
cwd: fixture.root,
|
|
stdio: "ignore",
|
|
}),
|
|
).toThrow();
|
|
|
|
const plan = produceReleasePlan(sourceParams(fixture));
|
|
expect(plan).toMatchObject({
|
|
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).toMatchObject({
|
|
ref: fixture.toolingFullRef,
|
|
sha: fixture.toolingSha,
|
|
});
|
|
expect(plan.validation).toEqual({
|
|
allowed_groups: ["all", "ci", "package"],
|
|
intent: "release-beta",
|
|
profile: "beta",
|
|
soak: false,
|
|
});
|
|
expect(plan.inventory.packages).toEqual([
|
|
{ name: "@openclaw/ai", targets: ["npm"], version: "2026.8.1-beta.2" },
|
|
{ name: "@openclaw/gateway-client", targets: ["npm"], version: "2026.8.1-beta.2" },
|
|
{ name: "@openclaw/gateway-protocol", targets: ["npm"], version: "2026.8.1-beta.2" },
|
|
{ name: "openclaw", targets: ["npm"], version: "2026.8.1-beta.2" },
|
|
]);
|
|
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("requires the final tag only for postpublish confidence", () => {
|
|
const fixture = createFixtureRepo();
|
|
expect(produceReleasePlan(sourceParams(fixture))).toMatchObject({
|
|
purpose: "beta-publish",
|
|
target_context_ref: fixture.candidateRef,
|
|
});
|
|
expect(() => produceReleasePlan(sourceParams(fixture, "postpublish-confidence"))).toThrow(
|
|
"published candidate tag does not resolve",
|
|
);
|
|
|
|
execFileSync(
|
|
"git",
|
|
["tag", fixture.candidateRef.slice("refs/tags/".length), fixture.candidateSha],
|
|
{
|
|
cwd: fixture.root,
|
|
},
|
|
);
|
|
expect(produceReleasePlan(sourceParams(fixture, "postpublish-confidence"))).toMatchObject({
|
|
purpose: "postpublish-confidence",
|
|
target_context_ref: fixture.candidateRef,
|
|
validation: {
|
|
intent: "diagnostic-full",
|
|
profile: "full",
|
|
soak: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("produces tagless diagnostics from trusted main or protected tooling", () => {
|
|
const fixture = createFixtureRepo();
|
|
expect(produceReleasePlan(sourceParams(fixture, "diagnostic"))).toMatchObject({
|
|
candidate_sha: fixture.candidateSha,
|
|
purpose: "diagnostic",
|
|
tag: null,
|
|
target_context_ref: fixture.candidateSha,
|
|
tooling: {
|
|
ref: fixture.toolingFullRef,
|
|
sha: fixture.toolingSha,
|
|
},
|
|
validation: {
|
|
intent: "diagnostic-full",
|
|
profile: "full",
|
|
soak: true,
|
|
},
|
|
});
|
|
|
|
expect(
|
|
produceReleasePlan({
|
|
...sourceParams(fixture, "diagnostic"),
|
|
toolingFullRef: "refs/heads/main",
|
|
runGh: (args) => {
|
|
expect(args[1]).toBe(`repos/openclaw/openclaw/compare/${fixture.toolingSha}...main`);
|
|
return JSON.stringify({ status: "identical" });
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
purpose: "diagnostic",
|
|
tag: null,
|
|
target_context_ref: fixture.candidateSha,
|
|
tooling: {
|
|
ref: "refs/heads/main",
|
|
sha: fixture.toolingSha,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("requires main qualification producers to choose daily or weekly", () => {
|
|
const fixture = createFixtureRepo();
|
|
expect(() => produceReleasePlan(sourceParams(fixture, "main-qualification"))).toThrow(
|
|
"requires an explicit validation intent",
|
|
);
|
|
expect(
|
|
produceReleasePlan(sourceParams(fixture, "main-qualification", "main-daily")).validation,
|
|
).toMatchObject({
|
|
intent: "main-daily",
|
|
profile: "beta",
|
|
soak: false,
|
|
});
|
|
expect(
|
|
produceReleasePlan(sourceParams(fixture, "main-qualification", "main-weekly")).validation,
|
|
).toMatchObject({
|
|
intent: "main-weekly",
|
|
profile: "full",
|
|
soak: true,
|
|
});
|
|
});
|
|
|
|
it("requires exact candidate and tooling identity instead of checkout HEAD", () => {
|
|
const fixture = createFixtureRepo();
|
|
expect(() =>
|
|
produceReleasePlan({ ...sourceParams(fixture), candidateSha: "f".repeat(40) }),
|
|
).toThrow("candidate SHA does not resolve");
|
|
expect(() =>
|
|
produceReleasePlan({
|
|
...sourceParams(fixture),
|
|
runGh: trustedToolingGh(fixture.toolingFullRef, fixture.candidateSha),
|
|
}),
|
|
).toThrow(
|
|
"protected release tooling tag is missing, moved, annotated, or bound to the wrong SHA",
|
|
);
|
|
expect(() =>
|
|
produceReleasePlan({ ...sourceParams(fixture), candidateRef: "refs/heads/tooling" }),
|
|
).toThrow("candidate ref must be");
|
|
});
|
|
|
|
it("rejects a locally forged protected tooling tag that GitHub does not own", () => {
|
|
const fixture = createFixtureRepo();
|
|
const forgedFullRef = `refs/tags/release-publish/${fixture.toolingSha.slice(0, 12)}-999`;
|
|
execFileSync("git", ["tag", forgedFullRef.slice("refs/tags/".length), fixture.toolingSha], {
|
|
cwd: fixture.root,
|
|
});
|
|
|
|
expect(() =>
|
|
produceReleasePlan({
|
|
...sourceParams(fixture),
|
|
toolingFullRef: forgedFullRef,
|
|
runGh: () => {
|
|
throw new Error("HTTP 404");
|
|
},
|
|
}),
|
|
).toThrow("protected release tooling tag is missing or unreadable");
|
|
});
|
|
|
|
it("rejects a caller producer that differs from the exact tooling commit", () => {
|
|
const { result } = runYamlPackageSubprocess({
|
|
mutate: ({ fixture }) => {
|
|
const producerPath = join(fixture.root, "scripts/release-plan-producer.mts");
|
|
writeFileSync(producerPath, `${readFileSync(producerPath, "utf8")}\n// ambient mismatch\n`);
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("tooling bootstrap differs from tooling SHA");
|
|
});
|
|
|
|
it("rejects a byte-identical bootstrap launched from the candidate checkout", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
executionHead: (fixture) => fixture.candidateSha,
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("tooling bootstrap checkout HEAD must equal tooling SHA");
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("exports bootstrap operations for trusted-checkout callers", () => {
|
|
expect(trustedCheckoutProduceReleasePlan).toBeTypeOf("function");
|
|
expect(trustedCheckoutVerifyReleasePlanLock).toBeTypeOf("function");
|
|
});
|
|
|
|
it("rejects a forged remote tooling SHA before executing ambient tooling", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
mutate: ({ fixture, sentinelPath: sentinel }) => {
|
|
writeFixture(
|
|
fixture.root,
|
|
"scripts/release-plan-producer-core.mts",
|
|
`require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "ambient-core");\n`,
|
|
);
|
|
},
|
|
remoteToolingSha: ({ fixture }) => fixture.candidateSha,
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(
|
|
"protected release tooling tag is missing, moved, annotated, or bound to the wrong SHA",
|
|
);
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("ignores uncommitted ambient tooling after verifying the exact tooling commit", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
mutate: ({ fixture, sentinelPath: sentinel }) => {
|
|
writeFixture(
|
|
fixture.root,
|
|
"scripts/release-plan-producer-core.mts",
|
|
`require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "ambient-core");\n`,
|
|
);
|
|
},
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("rejects a symlinked tooling closure before executing its target", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
mutateTooling: (fixture) => {
|
|
const targetPath = join(fixture.root, "scripts/release-plan-producer-core.mts");
|
|
const sentinelTarget = join(fixture.root, "scripts/release-plan-producer-evil.mts");
|
|
writeFileSync(
|
|
sentinelTarget,
|
|
`require("node:fs").writeFileSync(${JSON.stringify(join(fixture.root, "yaml-executed"))}, "linked-core");\n`,
|
|
);
|
|
unlinkSync(targetPath);
|
|
symlinkSync("release-plan-producer-evil.mts", targetPath);
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("tooling closure path must be a regular Git blob");
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("rejects package imports in the retained tooling closure before execution", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
mutateTooling: (fixture) => {
|
|
const corePath = join(fixture.root, "scripts/release-plan-producer-core.mts");
|
|
writeFileSync(
|
|
corePath,
|
|
`import "yaml";\nrequire("node:fs").writeFileSync(${JSON.stringify(join(fixture.root, "yaml-executed"))}, "bare-import");\n${readFileSync(corePath, "utf8")}`,
|
|
);
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("tooling closure contains an unowned import");
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("rejects ambiguous tooling aliases before execution", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
mutateTooling: (fixture) => {
|
|
writeFixture(
|
|
fixture.root,
|
|
"scripts/lib/release-version.mts",
|
|
`require("node:fs").writeFileSync(${JSON.stringify(join(fixture.root, "yaml-executed"))}, "ambiguous-alias");\n`,
|
|
);
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("tooling import must resolve to one unambiguous file");
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("rejects foreign yaml resolved from a distinct execution root", () => {
|
|
const fixture = createFixtureRepo();
|
|
writeFixture(
|
|
fixture.root,
|
|
"node_modules/yaml/package.json",
|
|
JSON.stringify({
|
|
name: "yaml",
|
|
version: "9.9.9",
|
|
type: "commonjs",
|
|
main: "./index.cjs",
|
|
exports: {
|
|
".": "./index.cjs",
|
|
"./package.json": "./package.json",
|
|
},
|
|
}),
|
|
);
|
|
writeFixture(
|
|
fixture.root,
|
|
"node_modules/yaml/index.cjs",
|
|
`
|
|
exports.parse = source => {
|
|
if (source.includes("rerun_group")) {
|
|
return { on: { workflow_dispatch: { inputs: { rerun_group: { options: ["package", "all", "ci"] } } } } };
|
|
}
|
|
if (source.includes("CORE_PACKAGE_DIRS")) {
|
|
return { jobs: { preflight: { steps: [{ env: { CORE_PACKAGE_DIRS: "packages/ai packages/gateway-protocol packages/gateway-client" } }] } } };
|
|
}
|
|
return {
|
|
jobs: {
|
|
publish_docker: { uses: "./.github/workflows/docker-release.yml" },
|
|
publish_vcr: { uses: "./.github/workflows/vercel-container-registry-publish.yml" }
|
|
}
|
|
};
|
|
};
|
|
`,
|
|
);
|
|
writeFixture(
|
|
fixture.root,
|
|
"foreign-yaml-harness.mts",
|
|
`
|
|
import { produceReleasePlan } from "./scripts/release-plan-producer.mts";
|
|
|
|
const toolingFullRef = ${JSON.stringify(fixture.toolingFullRef)};
|
|
const toolingSha = ${JSON.stringify(fixture.toolingSha)};
|
|
produceReleasePlan({
|
|
repoRoot: ${JSON.stringify(fixture.root)},
|
|
intent: "publish",
|
|
candidateSha: ${JSON.stringify(fixture.candidateSha)},
|
|
candidateRef: ${JSON.stringify(fixture.candidateRef)},
|
|
toolingSha,
|
|
toolingFullRef,
|
|
runGh: () => JSON.stringify({
|
|
ref: toolingFullRef,
|
|
object: { type: "commit", sha: toolingSha },
|
|
}),
|
|
});
|
|
`,
|
|
);
|
|
const result = spawnSync(process.execPath, [join(fixture.root, "foreign-yaml-harness.mts")], {
|
|
cwd: fixture.root,
|
|
encoding: "utf8",
|
|
});
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("verified yaml retained tree digest mismatch");
|
|
});
|
|
|
|
it("accepts the complete installed yaml package tree", () => {
|
|
const { result, tempRoot } = runYamlPackageSubprocess();
|
|
expect(result.stderr).toBe("");
|
|
expect(result.status).toBe(0);
|
|
expect(yamlTempEntries(tempRoot)).toEqual([]);
|
|
});
|
|
|
|
it("rejects a changed yaml entry before executing it", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
mutate: ({ packageRoot, sentinelPath: sentinel }) => {
|
|
const entryPath = join(packageRoot, "dist/index.js");
|
|
writeFileSync(
|
|
entryPath,
|
|
`require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "executed");\n${readFileSync(entryPath, "utf8")}`,
|
|
);
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("verified yaml retained tree digest mismatch");
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("rejects a changed yaml transitive module before execution", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
mutate: ({ packageRoot, sentinelPath: sentinel }) => {
|
|
const transitivePath = join(packageRoot, "dist/public-api.js");
|
|
writeFileSync(
|
|
transitivePath,
|
|
`require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "executed");\n${readFileSync(transitivePath, "utf8")}`,
|
|
);
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("verified yaml retained tree digest mismatch");
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("rejects internal yaml package symlinks", () => {
|
|
const { result } = runYamlPackageSubprocess({
|
|
mutate: ({ packageRoot }) => {
|
|
const transitivePath = join(packageRoot, "dist/public-api.js");
|
|
unlinkSync(transitivePath);
|
|
symlinkSync("index.js", transitivePath);
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("installed yaml package must not contain symbolic links");
|
|
});
|
|
|
|
it("rejects extra yaml package files", () => {
|
|
const { result } = runYamlPackageSubprocess({
|
|
mutate: ({ packageRoot }) => {
|
|
writeFileSync(join(packageRoot, "unexpected.js"), "module.exports = {};\n");
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("verified yaml retained tree digest mismatch");
|
|
});
|
|
|
|
it("rejects missing yaml transitive files through tree attestation", () => {
|
|
const { result } = runYamlPackageSubprocess({
|
|
mutate: ({ packageRoot }) => {
|
|
rmSync(join(packageRoot, "dist/public-api.js"));
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("verified yaml retained tree digest mismatch");
|
|
expect(result.stderr).not.toContain("MODULE_NOT_FOUND");
|
|
});
|
|
|
|
it("ignores poisoned direct yaml require cache entries", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
beforeProduce: ({ sentinelPath: sentinel }) => `
|
|
const cacheModule = await import("node:module");
|
|
const cacheFs = await import("node:fs");
|
|
const cacheRequire = cacheModule.createRequire(import.meta.url);
|
|
const ambientEntry = cacheRequire.resolve("yaml");
|
|
cacheRequire(ambientEntry);
|
|
cacheRequire.cache[ambientEntry].exports = {
|
|
parse() {
|
|
cacheFs.writeFileSync(${JSON.stringify(sentinel)}, "direct-cache");
|
|
return {};
|
|
}
|
|
};
|
|
`,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("ignores poisoned transitive yaml require cache entries", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
beforeProduce: ({ sentinelPath: sentinel }) => `
|
|
const cacheModule = await import("node:module");
|
|
const cacheFs = await import("node:fs");
|
|
const cachePath = await import("node:path");
|
|
const cacheRequire = cacheModule.createRequire(import.meta.url);
|
|
const ambientEntry = cacheRequire.resolve("yaml");
|
|
cacheRequire(ambientEntry);
|
|
const publicApiPath = cachePath.join(cachePath.dirname(ambientEntry), "public-api.js");
|
|
cacheRequire.cache[publicApiPath].exports = {
|
|
parse() {
|
|
cacheFs.writeFileSync(${JSON.stringify(sentinel)}, "transitive-cache");
|
|
return {};
|
|
},
|
|
parseAllDocuments() {},
|
|
parseDocument() {},
|
|
stringify() {}
|
|
};
|
|
delete cacheRequire.cache[ambientEntry];
|
|
`,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("ignores parent yaml Module extension hooks", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
beforeProduce: ({ sentinelPath: sentinel }) => `
|
|
const hookModule = (await import("node:module")).createRequire(import.meta.url)("node:module");
|
|
const hookFs = await import("node:fs");
|
|
const originalJsLoader = hookModule._extensions[".js"];
|
|
hookModule._extensions[".js"] = function(module, filename) {
|
|
if (filename.includes("/yaml/") || filename.includes("openclaw-release-yaml-")) {
|
|
hookFs.writeFileSync(${JSON.stringify(sentinel)}, "extension-hook");
|
|
}
|
|
return originalJsLoader(module, filename);
|
|
};
|
|
`,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("does not inherit yaml NODE_OPTIONS or NODE_PATH loaders", () => {
|
|
const { result, sentinelPath } = runYamlPackageSubprocess({
|
|
mutate: ({ fixture, sentinelPath: sentinel }) => {
|
|
writeFixture(
|
|
fixture.root,
|
|
"yaml-preload.cjs",
|
|
`
|
|
if (process.argv.some(value => value.includes("__openclaw_verified_yaml__"))) {
|
|
require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "node-options");
|
|
}
|
|
`,
|
|
);
|
|
writeFixture(
|
|
fixture.root,
|
|
"evil-node-path/yaml/index.js",
|
|
`require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "node-path");`,
|
|
);
|
|
},
|
|
environment: ({ fixture }) => ({
|
|
NODE_OPTIONS: `--require=${join(fixture.root, "yaml-preload.cjs")}`,
|
|
NODE_PATH: join(fixture.root, "evil-node-path"),
|
|
}),
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
});
|
|
|
|
it("accepts exact-byte yaml hardlinks without creating a snapshot", () => {
|
|
const { result, tempRoot } = runYamlPackageSubprocess({
|
|
mutate: ({ fixture, packageRoot }) => {
|
|
const targetPath = join(packageRoot, "dist/public-api.js");
|
|
const linkSource = join(fixture.root, "public-api-hardlink-source.js");
|
|
writeFileSync(linkSource, readFileSync(targetPath));
|
|
unlinkSync(targetPath);
|
|
linkSync(linkSource, targetPath);
|
|
expect(statSync(targetPath).nlink).toBe(2);
|
|
},
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(yamlTempEntries(tempRoot)).toEqual([]);
|
|
});
|
|
|
|
it("ignores a same-size ambient yaml replacement after byte retention", () => {
|
|
const mutationPath = "ambient-mutated";
|
|
const { result, sentinelPath, tempRoot } = runYamlPackageSubprocess({
|
|
beforeImport: ({ fixture, packageRoot, sentinelPath: sentinel }) => `
|
|
const mutateModule = await import("node:module");
|
|
const mutateFs = await import("node:fs");
|
|
const mutateChildProcess = mutateModule.createRequire(import.meta.url)("node:child_process");
|
|
const originalExecFileSync = mutateChildProcess.execFileSync;
|
|
mutateChildProcess.execFileSync = function(command, args, options) {
|
|
if (command === process.execPath && args?.[0] === "--input-type=module") {
|
|
const entryPath = ${JSON.stringify(join(packageRoot, "dist/index.js"))};
|
|
const original = mutateFs.readFileSync(entryPath, "utf8");
|
|
const malicious =
|
|
'require("node:fs").writeFileSync(${JSON.stringify(sentinel)}, "ambient");' +
|
|
"module.exports={parse:JSON.parse};";
|
|
mutateFs.writeFileSync(entryPath, malicious.padEnd(original.length, " "));
|
|
mutateFs.writeFileSync(${JSON.stringify(join(fixture.root, mutationPath))}, "done");
|
|
}
|
|
return originalExecFileSync(command, args, options);
|
|
};
|
|
mutateModule.syncBuiltinESMExports();
|
|
`,
|
|
});
|
|
expect(result.status).toBe(0);
|
|
expect(existsSync(join(result.status === 0 ? dirname(sentinelPath) : "", mutationPath))).toBe(
|
|
true,
|
|
);
|
|
expect(existsSync(sentinelPath)).toBe(false);
|
|
expect(yamlTempEntries(tempRoot)).toEqual([]);
|
|
});
|
|
|
|
it("fails malformed combined yaml parsing without creating a snapshot", () => {
|
|
const { result, tempRoot } = runYamlPackageSubprocess({
|
|
mutateTooling: (fixture) => {
|
|
writeFixture(fixture.root, ".github/workflows/full-release-validation.yml", "on: [\n");
|
|
},
|
|
});
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain("Flow sequence in block collection");
|
|
expect(yamlTempEntries(tempRoot)).toEqual([]);
|
|
});
|
|
|
|
it("rejects malformed publishable plugins while producing the plan", () => {
|
|
const fixture = createFixtureRepo("2026.8.1-beta.2", { malformedPlugin: true });
|
|
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(
|
|
/Publishable plugin metadata validation failed:[\s\S]*private must not be true[\s\S]*type must be "module"[\s\S]*README\.md must exist/u,
|
|
);
|
|
});
|
|
|
|
it("fails closed on malformed candidate manifests across both publishers and ReleasePlan", () => {
|
|
const fixture = createFixtureRepo("2026.8.1-beta.2", { malformedPluginJson: true });
|
|
const error = "plugin candidate manifest is malformed JSON: extensions/broken/package.json";
|
|
expect(() => collectPublishablePluginPackages(fixture.root)).toThrow(error);
|
|
expect(() => collectClawHubPublishablePluginPackages(fixture.root)).toThrow(error);
|
|
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(error);
|
|
});
|
|
|
|
it("rejects duplicate package names split across npm and ClawHub plugin sources", () => {
|
|
const fixture = createFixtureRepo("2026.8.1-beta.2", {
|
|
duplicateCrossTargetPackageName: true,
|
|
});
|
|
|
|
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(
|
|
"package @openclaw/duplicate is declared by multiple plugin sources",
|
|
);
|
|
});
|
|
|
|
it("rejects a plugin package name that collides with a core package source", () => {
|
|
const fixture = createFixtureRepo("2026.8.1-beta.2", {
|
|
corePackageNameCollision: true,
|
|
});
|
|
|
|
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(
|
|
"package inventory source mismatch for @openclaw/ai: extensions/shadow-ai/package.json and packages/ai/package.json",
|
|
);
|
|
});
|
|
|
|
it("rejects conflicting platform publication sources with the same id", () => {
|
|
const fixture = createFixtureRepo("2026.8.1-beta.2", {
|
|
conflictingPlatformId: true,
|
|
});
|
|
|
|
expect(() => produceReleasePlan(sourceParams(fixture))).toThrow(
|
|
"declares conflicting platform windows: .github/workflows/windows-node-release.yml and .github/workflows/docker-release.yml",
|
|
);
|
|
});
|
|
|
|
it("matches the exact current publisher inventory: 93 npm and 89 ClawHub packages", () => {
|
|
const root = tempDirs.make("openclaw-release-plan-current-");
|
|
const candidateSha = execFileSync("git", ["rev-parse", "HEAD"], {
|
|
cwd: resolve("."),
|
|
encoding: "utf8",
|
|
}).trim();
|
|
execFileSync("git", ["clone", "-q", "--shared", "--no-checkout", resolve("."), root]);
|
|
execFileSync("git", ["checkout", "-q", "--detach", candidateSha], { cwd: root });
|
|
copyToolingClosure(root);
|
|
const toolingSha = commit(root, "tooling overlay", { allowEmpty: true });
|
|
execFileSync("git", ["update-ref", "refs/heads/main", toolingSha], { cwd: root });
|
|
|
|
const plan = produceReleasePlan({
|
|
repoRoot: root,
|
|
intent: "main-qualification",
|
|
validationIntent: "main-weekly",
|
|
candidateSha,
|
|
candidateRef: candidateSha,
|
|
toolingSha,
|
|
toolingFullRef: "refs/heads/main",
|
|
runGh: () => JSON.stringify({ status: "identical" }),
|
|
});
|
|
const npmPackages = plan.inventory.packages.filter((entry) => entry.targets.includes("npm"));
|
|
const clawHubPackages = plan.inventory.packages.filter((entry) =>
|
|
entry.targets.includes("clawhub"),
|
|
);
|
|
expect(npmPackages).toHaveLength(93);
|
|
expect(clawHubPackages).toHaveLength(89);
|
|
const coreNpmPackages = new Set([
|
|
"@openclaw/ai",
|
|
"@openclaw/gateway-client",
|
|
"@openclaw/gateway-protocol",
|
|
"openclaw",
|
|
]);
|
|
expect(
|
|
npmPackages
|
|
.map((entry) => entry.name)
|
|
.filter((name) => !coreNpmPackages.has(name))
|
|
.toSorted(),
|
|
).toEqual(
|
|
collectPublishablePluginPackages(root)
|
|
.map((plugin) => plugin.packageName)
|
|
.toSorted(),
|
|
);
|
|
expect(clawHubPackages.map((entry) => entry.name).toSorted()).toEqual(
|
|
collectClawHubPublishablePluginPackages(root)
|
|
.map((plugin) => plugin.packageName)
|
|
.toSorted(),
|
|
);
|
|
expect(npmPackages.map((entry) => entry.name)).toEqual(
|
|
expect.arrayContaining([
|
|
"@openclaw/ai",
|
|
"@openclaw/gateway-client",
|
|
"@openclaw/gateway-protocol",
|
|
"openclaw",
|
|
]),
|
|
);
|
|
});
|
|
|
|
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",
|
|
);
|
|
}
|
|
});
|
|
});
|