mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 13:49:36 +00:00
527 lines
16 KiB
TypeScript
527 lines
16 KiB
TypeScript
// Release Candidate Checklist tests cover release candidate checklist script behavior.
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildPublishCommand,
|
|
candidateParallelsArgs,
|
|
candidateParallelsShellCommand,
|
|
githubApi,
|
|
parseArgs,
|
|
parseRunIdFromDispatchOutput,
|
|
resolveArtifactName,
|
|
requireRunIdFromDispatchOutput,
|
|
validateFullManifest,
|
|
validateWindowsSourceRelease,
|
|
} from "../../scripts/release-candidate-checklist.mjs";
|
|
|
|
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
return new Response(JSON.stringify(body), init);
|
|
}
|
|
|
|
async function withGithubApiTimeoutEnv<T>(value: string, fn: () => Promise<T>): Promise<T> {
|
|
const previous = process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS;
|
|
process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS = value;
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
if (previous === undefined) {
|
|
delete process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS;
|
|
} else {
|
|
process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS = previous;
|
|
}
|
|
}
|
|
}
|
|
|
|
describe("release candidate checklist", () => {
|
|
it("infers validation profiles from candidate tags", () => {
|
|
expect(parseArgs(["--tag", "v2026.5.14-beta.3"]).releaseProfile).toBe("beta");
|
|
expect(parseArgs(["--tag", "v2026.5.14", "--windows-node-tag", "v0.6.3"]).releaseProfile).toBe(
|
|
"stable",
|
|
);
|
|
expect(
|
|
parseArgs([
|
|
"--tag",
|
|
"v2026.5.14",
|
|
"--windows-node-tag",
|
|
"v0.6.3",
|
|
"--release-profile",
|
|
"full",
|
|
]).releaseProfile,
|
|
).toBe("full");
|
|
});
|
|
|
|
it("runs Parallels against the exact prepared candidate tarball", () => {
|
|
expect(candidateParallelsArgs(".artifacts/preflight/openclaw.tgz")).toEqual([
|
|
"test:parallels:npm-update",
|
|
"--",
|
|
"--target-tarball",
|
|
".artifacts/preflight/openclaw.tgz",
|
|
"--json",
|
|
]);
|
|
expect(
|
|
candidateParallelsShellCommand(
|
|
".artifacts/preflight/openclaw candidate.tgz",
|
|
"/opt/homebrew/bin/gtimeout",
|
|
),
|
|
).toContain(
|
|
"set -a; source \"$HOME/.profile\" >/dev/null 2>&1 || true; set +a; exec '/opt/homebrew/bin/gtimeout' --foreground 150m pnpm",
|
|
);
|
|
expect(
|
|
candidateParallelsShellCommand(
|
|
".artifacts/preflight/openclaw candidate.tgz",
|
|
"/opt/homebrew/bin/gtimeout",
|
|
),
|
|
).toContain("'--target-tarball' '.artifacts/preflight/openclaw candidate.tgz'");
|
|
});
|
|
|
|
it("requires run ids when dispatch is disabled", () => {
|
|
expect(() => parseArgs(["--tag", "v2026.5.14-beta.3", "--skip-dispatch"])).toThrow(
|
|
"--skip-dispatch requires --full-release-run and --npm-preflight-run",
|
|
);
|
|
});
|
|
|
|
it("rejects duplicate release candidate CLI options", () => {
|
|
const requiredArgs = ["--tag", "v2026.5.14-beta.3"];
|
|
const duplicateOption = (
|
|
flag: string,
|
|
firstValue: string,
|
|
secondValue: string,
|
|
prefix = requiredArgs,
|
|
): [string, string[]] => [flag, [...prefix, flag, firstValue, flag, secondValue]];
|
|
const duplicateFlag = (flag: string): [string, string[]] => [flag, [...requiredArgs, flag, flag]];
|
|
const duplicateCases = [
|
|
duplicateOption("--tag", "v2026.5.14-beta.3", "v2026.5.14-beta.4", []),
|
|
duplicateOption("--workflow-ref", "release/a", "release/b"),
|
|
duplicateOption("--repo", "openclaw/openclaw", "fork/openclaw"),
|
|
duplicateOption("--full-release-run", "111", "222"),
|
|
duplicateOption("--npm-preflight-run", "111", "222"),
|
|
duplicateOption("--windows-node-tag", "v0.6.3", "v0.6.4"),
|
|
duplicateFlag("--skip-dispatch"),
|
|
duplicateFlag("--skip-local-generated-check"),
|
|
duplicateFlag("--skip-parallels"),
|
|
duplicateFlag("--skip-telegram"),
|
|
duplicateOption("--telegram-provider-mode", "mock-openai", "live-frontier"),
|
|
duplicateOption("--provider", "blacksmith-testbox", "crabbox"),
|
|
duplicateOption("--mode", "fresh", "upgrade"),
|
|
duplicateOption("--release-profile", "beta", "stable"),
|
|
duplicateOption("--npm-dist-tag", "beta", "latest"),
|
|
duplicateOption("--plugin-publish-scope", "all-publishable", "selected"),
|
|
duplicateOption("--plugins", "telegram", "discord"),
|
|
duplicateOption("--output-dir", ".artifacts/a", ".artifacts/b"),
|
|
] satisfies Array<[string, string[]]>;
|
|
|
|
for (const [flag, args] of duplicateCases) {
|
|
expect(() => parseArgs(args), flag).toThrow(`${flag} was provided more than once`);
|
|
}
|
|
});
|
|
|
|
it("requires stable validation evidence to include soak and blocking performance", () => {
|
|
const stableManifest = {
|
|
workflowName: "Full Release Validation",
|
|
targetSha: "candidate-sha",
|
|
releaseProfile: "stable",
|
|
rerunGroup: "all",
|
|
runReleaseSoak: "true",
|
|
controls: { performanceBlocking: true },
|
|
};
|
|
|
|
expect(() =>
|
|
validateFullManifest(stableManifest, {
|
|
targetSha: "candidate-sha",
|
|
releaseProfile: "stable",
|
|
}),
|
|
).not.toThrow();
|
|
|
|
expect(() =>
|
|
validateFullManifest(
|
|
{
|
|
...stableManifest,
|
|
runReleaseSoak: "false",
|
|
},
|
|
{
|
|
targetSha: "candidate-sha",
|
|
releaseProfile: "stable",
|
|
},
|
|
),
|
|
).toThrow("runReleaseSoak=true");
|
|
expect(() =>
|
|
validateFullManifest(
|
|
{
|
|
...stableManifest,
|
|
controls: { performanceBlocking: false },
|
|
},
|
|
{
|
|
targetSha: "candidate-sha",
|
|
releaseProfile: "stable",
|
|
},
|
|
),
|
|
).toThrow("blocking product performance");
|
|
});
|
|
|
|
it("stops parsing options after the argument terminator", () => {
|
|
const options = parseArgs([
|
|
"--tag",
|
|
"v2026.5.14-beta.3",
|
|
"--full-release-run",
|
|
"111",
|
|
"--npm-preflight-run",
|
|
"222",
|
|
"--skip-dispatch",
|
|
"--",
|
|
"--plugin-publish-scope",
|
|
"selected",
|
|
]);
|
|
|
|
expect(options.pluginPublishScope).toBe("all-publishable");
|
|
});
|
|
|
|
it("accepts package-manager argument separators before script options", () => {
|
|
const options = parseArgs([
|
|
"--",
|
|
"--tag",
|
|
"v2026.5.14-beta.3",
|
|
"--full-release-run",
|
|
"111",
|
|
"--npm-preflight-run",
|
|
"222",
|
|
"--skip-dispatch",
|
|
"--skip-parallels",
|
|
]);
|
|
|
|
expect(options.tag).toBe("v2026.5.14-beta.3");
|
|
expect(options.skipParallels).toBe(true);
|
|
});
|
|
|
|
it("builds the gated release publish command from green evidence inputs", () => {
|
|
const options = {
|
|
...parseArgs([
|
|
"--tag",
|
|
"v2026.5.14-beta.3",
|
|
"--workflow-ref",
|
|
"release/2026.5.14",
|
|
"--full-release-run",
|
|
"111",
|
|
"--npm-preflight-run",
|
|
"222",
|
|
"--skip-dispatch",
|
|
]),
|
|
workflowRef: "release/2026.5.14",
|
|
};
|
|
|
|
expect(buildPublishCommand(options)).toContain("'full_release_validation_run_id=111'");
|
|
expect(buildPublishCommand(options)).toContain("'preflight_run_id=222'");
|
|
expect(buildPublishCommand(options)).toContain("'tag=v2026.5.14-beta.3'");
|
|
expect(buildPublishCommand(options)).toContain("'plugin_publish_scope=all-publishable'");
|
|
expect(buildPublishCommand(options)).not.toContain("windows_node_tag=");
|
|
});
|
|
|
|
it("requires and carries an exact Windows Node tag for stable release candidates", () => {
|
|
expect(() => parseArgs(["--tag", "v2026.5.14"])).toThrow(
|
|
"stable release candidates require --windows-node-tag",
|
|
);
|
|
expect(() => parseArgs(["--tag", "v2026.5.14", "--windows-node-tag", "latest"])).toThrow(
|
|
"--windows-node-tag must be an explicit version tag, not latest",
|
|
);
|
|
|
|
const options = {
|
|
...parseArgs([
|
|
"--tag",
|
|
"v2026.5.14",
|
|
"--windows-node-tag",
|
|
"v0.6.3",
|
|
"--workflow-ref",
|
|
"release/2026.5.14",
|
|
]),
|
|
workflowRef: "release/2026.5.14",
|
|
windowsNodeInstallerDigests: JSON.stringify({
|
|
"OpenClawCompanion-Setup-x64.exe": `sha256:${"a".repeat(64)}`,
|
|
"OpenClawCompanion-Setup-arm64.exe": `sha256:${"b".repeat(64)}`,
|
|
}),
|
|
};
|
|
|
|
expect(buildPublishCommand(options)).toContain("'windows_node_tag=v0.6.3'");
|
|
expect(buildPublishCommand(options)).toContain(
|
|
`'windows_node_installer_digests={"OpenClawCompanion-Setup-x64.exe":"sha256:${"a".repeat(64)}","OpenClawCompanion-Setup-arm64.exe":"sha256:${"b".repeat(64)}"}'`,
|
|
);
|
|
});
|
|
|
|
it("validates the stable Windows source release and immutable installer digests", async () => {
|
|
const assets = [
|
|
{
|
|
name: "OpenClawCompanion-Setup-x64.exe",
|
|
digest: `sha256:${"a".repeat(64)}`,
|
|
},
|
|
{
|
|
name: "OpenClawCompanion-Setup-arm64.exe",
|
|
digest: `sha256:${"b".repeat(64)}`,
|
|
},
|
|
];
|
|
const fetchImpl = vi.fn(async () => {
|
|
return jsonResponse({
|
|
tag_name: "v0.6.3",
|
|
draft: false,
|
|
prerelease: false,
|
|
html_url: "https://github.com/openclaw/openclaw-windows-node/releases/tag/v0.6.3",
|
|
assets,
|
|
});
|
|
});
|
|
|
|
await expect(
|
|
validateWindowsSourceRelease("v0.6.3", {
|
|
fetchImpl,
|
|
timeoutMs: 1234,
|
|
token: "test-token",
|
|
}),
|
|
).resolves.toEqual({
|
|
tag: "v0.6.3",
|
|
url: "https://github.com/openclaw/openclaw-windows-node/releases/tag/v0.6.3",
|
|
assets,
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
[{ draft: true }, "must be published"],
|
|
[{ prerelease: true }, "must not be a prerelease"],
|
|
[{ tag_name: "v0.6.4" }, "Windows source release tag mismatch: expected v0.6.3, got v0.6.4"],
|
|
[
|
|
{ assets: [] },
|
|
"must contain exactly one required asset OpenClawCompanion-Setup-x64.exe; found 0",
|
|
],
|
|
[
|
|
{
|
|
assets: [
|
|
{
|
|
name: "OpenClawCompanion-Setup-x64.exe",
|
|
digest: `sha256:${"a".repeat(64)}`,
|
|
},
|
|
{
|
|
name: "OpenClawCompanion-Setup-x64.exe",
|
|
digest: `sha256:${"c".repeat(64)}`,
|
|
},
|
|
{
|
|
name: "OpenClawCompanion-Setup-arm64.exe",
|
|
digest: `sha256:${"b".repeat(64)}`,
|
|
},
|
|
],
|
|
},
|
|
"must contain exactly one required asset OpenClawCompanion-Setup-x64.exe; found 2",
|
|
],
|
|
[
|
|
{
|
|
assets: [
|
|
{ name: "OpenClawCompanion-Setup-x64.exe", digest: "" },
|
|
{ name: "OpenClawCompanion-Setup-arm64.exe", digest: `sha256:${"b".repeat(64)}` },
|
|
],
|
|
},
|
|
"asset OpenClawCompanion-Setup-x64.exe is missing its SHA-256 digest",
|
|
],
|
|
])("rejects an invalid stable Windows source release", async (override, message) => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
return jsonResponse({
|
|
tag_name: "v0.6.3",
|
|
draft: false,
|
|
prerelease: false,
|
|
html_url: "https://github.com/openclaw/openclaw-windows-node/releases/tag/v0.6.3",
|
|
assets: [
|
|
{
|
|
name: "OpenClawCompanion-Setup-x64.exe",
|
|
digest: `sha256:${"a".repeat(64)}`,
|
|
},
|
|
{
|
|
name: "OpenClawCompanion-Setup-arm64.exe",
|
|
digest: `sha256:${"b".repeat(64)}`,
|
|
},
|
|
],
|
|
...override,
|
|
});
|
|
});
|
|
|
|
await expect(
|
|
validateWindowsSourceRelease("v0.6.3", {
|
|
fetchImpl,
|
|
timeoutMs: 1234,
|
|
token: "test-token",
|
|
}),
|
|
).rejects.toThrow(message);
|
|
});
|
|
|
|
it("carries the Telegram proof run into the publish command when available", () => {
|
|
const options = {
|
|
...parseArgs([
|
|
"--tag",
|
|
"v2026.5.14-beta.3",
|
|
"--workflow-ref",
|
|
"release/2026.5.14",
|
|
"--full-release-run",
|
|
"111",
|
|
"--npm-preflight-run",
|
|
"222",
|
|
"--skip-dispatch",
|
|
]),
|
|
workflowRef: "release/2026.5.14",
|
|
npmTelegramRunId: "333",
|
|
};
|
|
|
|
expect(buildPublishCommand(options)).toContain("'npm_telegram_run_id=333'");
|
|
});
|
|
|
|
it("requires explicit plugin names for selected plugin publish scope", () => {
|
|
expect(() =>
|
|
parseArgs(["--tag", "v2026.5.14-beta.3", "--plugin-publish-scope", "selected"]),
|
|
).toThrow("--plugin-publish-scope selected requires --plugins");
|
|
});
|
|
|
|
it("rejects selected plugin publish scope for release candidates", () => {
|
|
expect(() =>
|
|
parseArgs([
|
|
"--tag",
|
|
"v2026.5.14-beta.3",
|
|
"--plugin-publish-scope",
|
|
"selected",
|
|
"--plugins",
|
|
"@openclaw/diffs",
|
|
]),
|
|
).toThrow("release candidates publish OpenClaw with --plugin-publish-scope all-publishable");
|
|
});
|
|
|
|
it("extracts a workflow run id from gh dispatch output", () => {
|
|
expect(
|
|
parseRunIdFromDispatchOutput(
|
|
"https://github.com/openclaw/openclaw/actions/runs/25922042055\n",
|
|
),
|
|
).toBe("25922042055");
|
|
});
|
|
|
|
it("fails closed when gh dispatch output does not include the run url", () => {
|
|
expect(() =>
|
|
requireRunIdFromDispatchOutput(
|
|
"Created workflow_dispatch event for full-release-validation.yml",
|
|
"full-release-validation.yml",
|
|
),
|
|
).toThrow("refusing to guess from recent workflow_dispatch runs");
|
|
});
|
|
|
|
it("falls back to a single compatible artifact from the same run", () => {
|
|
expect(
|
|
resolveArtifactName(
|
|
[{ name: "openclaw-npm-preflight-dba00", expired: false }],
|
|
"openclaw-npm-preflight-v2026.5.16-beta.2",
|
|
"openclaw-npm-preflight-",
|
|
),
|
|
).toBe("openclaw-npm-preflight-dba00");
|
|
});
|
|
|
|
it("bounds GitHub API requests with a timeout signal", async () => {
|
|
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
|
expect(init?.headers).toMatchObject({
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: "Bearer test-token",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
});
|
|
return jsonResponse({ workflow_runs: [] });
|
|
});
|
|
|
|
await expect(
|
|
githubApi("repos/openclaw/openclaw/actions/runs", {
|
|
fetchImpl,
|
|
timeoutMs: 1234,
|
|
token: "test-token",
|
|
}),
|
|
).resolves.toEqual({ workflow_runs: [] });
|
|
expect(fetchImpl).toHaveBeenCalledWith(
|
|
"https://api.github.com/repos/openclaw/openclaw/actions/runs",
|
|
expect.objectContaining({
|
|
signal: expect.any(AbortSignal),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses a positive integer GitHub API timeout env", async () => {
|
|
const fetchImpl = vi.fn(async (_url: string, init?: RequestInit) => {
|
|
expect(init?.signal).toBeInstanceOf(AbortSignal);
|
|
return jsonResponse({ workflow_runs: [] });
|
|
});
|
|
|
|
await withGithubApiTimeoutEnv("2500", async () => {
|
|
await expect(
|
|
githubApi("repos/openclaw/openclaw/actions/runs", {
|
|
fetchImpl,
|
|
token: "test-token",
|
|
}),
|
|
).resolves.toEqual({ workflow_runs: [] });
|
|
});
|
|
expect(fetchImpl).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it.each(["1e3", "10.5", "0", "soon"])(
|
|
"rejects malformed GitHub API timeout env %s",
|
|
async (raw) => {
|
|
const fetchImpl = vi.fn();
|
|
|
|
await withGithubApiTimeoutEnv(raw, async () => {
|
|
await expect(
|
|
githubApi("repos/openclaw/openclaw/actions/runs", {
|
|
fetchImpl,
|
|
token: "test-token",
|
|
}),
|
|
).rejects.toThrow(
|
|
"OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive integer",
|
|
);
|
|
});
|
|
expect(fetchImpl).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("bounds GitHub API error bodies", async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response("x".repeat(65), {
|
|
headers: { "content-length": "65" },
|
|
status: 500,
|
|
});
|
|
});
|
|
|
|
await expect(
|
|
githubApi("repos/openclaw/openclaw/actions/runs", {
|
|
fetchImpl,
|
|
maxBodyBytes: 64,
|
|
timeoutMs: 1234,
|
|
token: "test-token",
|
|
}),
|
|
).rejects.toThrow(
|
|
"GitHub API repos/openclaw/openclaw/actions/runs response body exceeded 64 bytes",
|
|
);
|
|
});
|
|
|
|
it("keeps GitHub API timeouts active while reading response bodies", async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
return new Response(new ReadableStream<Uint8Array>({ start() {} }), {
|
|
status: 200,
|
|
});
|
|
});
|
|
|
|
await expect(
|
|
githubApi("repos/openclaw/openclaw/actions/runs", {
|
|
fetchImpl,
|
|
timeoutMs: 25,
|
|
token: "test-token",
|
|
}),
|
|
).rejects.toThrow("GitHub API repos/openclaw/openclaw/actions/runs timed out after 25ms");
|
|
});
|
|
|
|
it("includes the GitHub API path when a request times out", async () => {
|
|
const fetchImpl = vi.fn(async () => {
|
|
throw new DOMException("request timed out", "TimeoutError");
|
|
});
|
|
|
|
await expect(
|
|
githubApi("repos/openclaw/openclaw/actions/runs/123/jobs", {
|
|
fetchImpl,
|
|
timeoutMs: 5,
|
|
token: "test-token",
|
|
}),
|
|
).rejects.toThrow(
|
|
"GitHub API repos/openclaw/openclaw/actions/runs/123/jobs timed out after 5ms",
|
|
);
|
|
});
|
|
});
|