mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 19:08:22 -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
369 lines
12 KiB
TypeScript
369 lines
12 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
canonicalReleasePlanJson,
|
|
canonicalReleasePlanLockJson,
|
|
createReleasePlanLock,
|
|
parseReleasePlanLockJson,
|
|
RELEASE_PLAN_CANONICALIZATION,
|
|
validateReleasePlan,
|
|
} from "../../scripts/release-plan-contract.mjs";
|
|
|
|
const fixtureDir = resolve("test/fixtures");
|
|
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("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(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(() => parseReleasePlanLockJson(duplicate)).toThrow("duplicate key");
|
|
const nestedDuplicate = lockText.replace(
|
|
'"tooling":{"ref":',
|
|
'"tooling":{"ref":"ignored","\\u0072ef":',
|
|
);
|
|
expect(() => parseReleasePlanLockJson(nestedDuplicate)).toThrow("duplicate key");
|
|
const arrayEntryDuplicate = lockText.replace(
|
|
'{"name":"@openclaw/example","targets":',
|
|
'{"name":"ignored","name":"@openclaw/example","targets":',
|
|
);
|
|
expect(() => parseReleasePlanLockJson(arrayEntryDuplicate)).toThrow("duplicate key");
|
|
expect(() =>
|
|
parseReleasePlanLockJson(
|
|
`${JSON.stringify({
|
|
schema: lockFixture.schema,
|
|
plan: lockFixture.plan,
|
|
digest: lockFixture.digest,
|
|
})}\n`,
|
|
),
|
|
).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("rejects values that JSON.stringify would erase or coerce", () => {
|
|
const withUndefined = { ...sourceFixture, ignored: undefined };
|
|
expect(JSON.stringify(withUndefined)).toBe(JSON.stringify(sourceFixture));
|
|
expect(() => canonicalReleasePlanJson(withUndefined)).toThrow("unsupported undefined");
|
|
|
|
const withNan = { ...sourceFixture, tag: Number.NaN };
|
|
expect(JSON.stringify(withNan)).toBe(JSON.stringify({ ...sourceFixture, tag: null }));
|
|
expect(() => canonicalReleasePlanJson(withNan)).toThrow("must be finite");
|
|
|
|
for (const value of [
|
|
Number.POSITIVE_INFINITY,
|
|
Number.NEGATIVE_INFINITY,
|
|
() => undefined,
|
|
Symbol("ignored"),
|
|
1n,
|
|
]) {
|
|
expect(() => canonicalReleasePlanJson({ ...sourceFixture, ignored: value })).toThrow(
|
|
"canonical JSON",
|
|
);
|
|
}
|
|
expect(() => canonicalReleasePlanJson({ ...sourceFixture, ignored: -0 })).toThrow(
|
|
"negative zero",
|
|
);
|
|
});
|
|
|
|
it("rejects non-tree and non-data JSON structures", () => {
|
|
const sparseGroups = ["all", , "package"];
|
|
expect(JSON.stringify(sparseGroups)).toBe('["all",null,"package"]');
|
|
expect(() =>
|
|
canonicalReleasePlanJson({
|
|
...sourceFixture,
|
|
validation: {
|
|
...(sourceFixture.validation as Record<string, unknown>),
|
|
allowed_groups: sparseGroups,
|
|
},
|
|
}),
|
|
).toThrow("must be dense");
|
|
|
|
const augmentedGroups = ["all", "ci", "package"];
|
|
Object.defineProperty(augmentedGroups, "metadata", { enumerable: true, value: "ignored" });
|
|
expect(() =>
|
|
canonicalReleasePlanJson({
|
|
...sourceFixture,
|
|
validation: {
|
|
...(sourceFixture.validation as Record<string, unknown>),
|
|
allowed_groups: augmentedGroups,
|
|
},
|
|
}),
|
|
).toThrow("no extra properties");
|
|
|
|
const cyclic = { ...sourceFixture } as Record<string, unknown>;
|
|
cyclic.self = cyclic;
|
|
expect(() => canonicalReleasePlanJson(cyclic)).toThrow("must not contain cycles");
|
|
|
|
const nonPlain = Object.assign(new (class ReleasePlan {})(), sourceFixture);
|
|
expect(() => canonicalReleasePlanJson(nonPlain)).toThrow("must be plain");
|
|
|
|
const accessor = { ...sourceFixture };
|
|
Object.defineProperty(accessor, "hidden", {
|
|
enumerable: true,
|
|
get: () => "ignored",
|
|
});
|
|
expect(() => canonicalReleasePlanJson(accessor)).toThrow("enumerable data properties");
|
|
|
|
const symbolKey = { ...sourceFixture, [Symbol("ignored")]: true };
|
|
expect(() => canonicalReleasePlanJson(symbolKey)).toThrow("must use string keys");
|
|
});
|
|
|
|
it("enforces the purpose, version, tag, and target context matrix", () => {
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
purpose: "stable-publish",
|
|
validation: {
|
|
allowed_groups: ["all", "ci", "package"],
|
|
intent: "release-stable",
|
|
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"],
|
|
intent: "main-weekly",
|
|
profile: "full",
|
|
soak: true,
|
|
},
|
|
}),
|
|
).toThrow("candidate SHA context");
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
tag: "v2026.8.1-beta.3",
|
|
}),
|
|
).toThrow("exact version tag context");
|
|
});
|
|
|
|
it("binds purpose, intent, profile, and soak to one authoritative policy", () => {
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
validation: {
|
|
...(sourceFixture.validation as Record<string, unknown>),
|
|
intent: "release-stable",
|
|
},
|
|
}),
|
|
).toThrow("beta-publish does not allow validation intent");
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
validation: {
|
|
...(sourceFixture.validation as Record<string, unknown>),
|
|
profile: "full",
|
|
},
|
|
}),
|
|
).toThrow("profile assertion conflicts");
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
validation: {
|
|
...(sourceFixture.validation as Record<string, unknown>),
|
|
soak: true,
|
|
},
|
|
}),
|
|
).toThrow("soak assertion conflicts");
|
|
});
|
|
|
|
it("accepts daily and weekly main qualification intents", () => {
|
|
const mainPlan = {
|
|
...sourceFixture,
|
|
purpose: "main-qualification",
|
|
tag: null,
|
|
target_context_ref: sourceFixture.candidate_sha,
|
|
};
|
|
expect(
|
|
validateReleasePlan({
|
|
...mainPlan,
|
|
validation: {
|
|
allowed_groups: ["all", "ci", "package"],
|
|
intent: "main-daily",
|
|
profile: "beta",
|
|
soak: false,
|
|
},
|
|
}).validation.intent,
|
|
).toBe("main-daily");
|
|
expect(
|
|
validateReleasePlan({
|
|
...mainPlan,
|
|
validation: {
|
|
allowed_groups: ["all", "ci", "package"],
|
|
intent: "main-weekly",
|
|
profile: "full",
|
|
soak: true,
|
|
},
|
|
}).validation.intent,
|
|
).toBe("main-weekly");
|
|
});
|
|
|
|
it("keeps diagnostic plans tagless, non-publishable, and distinct from qualification", () => {
|
|
const diagnosticPlan = validateReleasePlan({
|
|
...sourceFixture,
|
|
purpose: "diagnostic",
|
|
tag: null,
|
|
target_context_ref: sourceFixture.candidate_sha,
|
|
tooling: {
|
|
...(sourceFixture.tooling as Record<string, unknown>),
|
|
ref: "refs/heads/main",
|
|
},
|
|
validation: {
|
|
allowed_groups: ["all", "ci", "package"],
|
|
intent: "diagnostic-full",
|
|
profile: "full",
|
|
soak: true,
|
|
},
|
|
});
|
|
expect(diagnosticPlan).toMatchObject({
|
|
purpose: "diagnostic",
|
|
tag: null,
|
|
target_context_ref: sourceFixture.candidate_sha,
|
|
validation: {
|
|
intent: "diagnostic-full",
|
|
profile: "full",
|
|
soak: true,
|
|
},
|
|
});
|
|
expect(diagnosticPlan.purpose).not.toBe("main-qualification");
|
|
expect(diagnosticPlan.purpose).not.toBe("postpublish-confidence");
|
|
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...diagnosticPlan,
|
|
tag: sourceFixture.tag,
|
|
target_context_ref: sourceFixture.target_context_ref,
|
|
}),
|
|
).toThrow("diagnostic release plans require a null tag and candidate SHA context");
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...diagnosticPlan,
|
|
validation: {
|
|
...diagnosticPlan.validation,
|
|
intent: "release-beta",
|
|
profile: "beta",
|
|
soak: false,
|
|
},
|
|
}),
|
|
).toThrow("diagnostic does not allow validation intent");
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...diagnosticPlan,
|
|
tooling: {
|
|
...diagnosticPlan.tooling,
|
|
ref: "refs/heads/feature",
|
|
},
|
|
}),
|
|
).toThrow("diagnostic tooling must use trusted main or a protected release-publish tag");
|
|
});
|
|
|
|
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,
|
|
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("supported release version");
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
inventory: {
|
|
...(sourceFixture.inventory as Record<string, unknown>),
|
|
packages: [
|
|
{ name: "openclaw", targets: ["npm"], version: "2026.8.1-beta.2" },
|
|
{
|
|
name: "@openclaw/example",
|
|
targets: ["clawhub", "npm"],
|
|
version: "2026.8.1-beta.2",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
).toThrow("ascending ASCII order");
|
|
});
|
|
|
|
it("rejects arbitrary tooling routes", () => {
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
tooling: {
|
|
...(sourceFixture.tooling as Record<string, unknown>),
|
|
ref: "refs/heads/main",
|
|
},
|
|
}),
|
|
).toThrow("protected release-publish tag");
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
tooling: {
|
|
...(sourceFixture.tooling as Record<string, unknown>),
|
|
ref: "refs/tags/release-publish/cccccccccccc-123",
|
|
},
|
|
}),
|
|
).toThrow("bound to its SHA");
|
|
expect(() =>
|
|
validateReleasePlan({
|
|
...sourceFixture,
|
|
purpose: "main-qualification",
|
|
tag: null,
|
|
target_context_ref: sourceFixture.candidate_sha,
|
|
tooling: {
|
|
...(sourceFixture.tooling as Record<string, unknown>),
|
|
ref: "refs/heads/feature",
|
|
},
|
|
validation: {
|
|
allowed_groups: ["all", "ci", "package"],
|
|
intent: "main-weekly",
|
|
profile: "full",
|
|
soak: true,
|
|
},
|
|
}),
|
|
).toThrow("trusted main");
|
|
});
|
|
|
|
it("keeps run and rerun state outside ReleasePlan", () => {
|
|
const plan = validateReleasePlan(sourceFixture);
|
|
expect(plan).not.toHaveProperty("run_id");
|
|
expect(plan).not.toHaveProperty("rerun_group");
|
|
expect(plan).not.toHaveProperty("filters");
|
|
});
|
|
});
|