ci: gate stable releases on Windows companion assets (#92555)

* ci: gate stable releases on Windows companion assets

* fix(release): reject malformed Windows checksum manifests

* fix(release): make Windows recovery fail closed

* fix(release): tighten Windows asset identity checks

* fix(release): validate prepared candidate tarballs

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Jason (Json)
2026-06-13 20:33:33 -06:00
committed by GitHub
parent 4001be54e4
commit 8ae1adfdcc
10 changed files with 1046 additions and 98 deletions
@@ -11,6 +11,7 @@ const SETUP_PNPM_STORE_CACHE_ACTION = ".github/actions/setup-pnpm-store-cache/ac
const DOCKER_E2E_PLAN_ACTION = ".github/actions/docker-e2e-plan/action.yml";
const RELEASE_CHECKS_WORKFLOW = ".github/workflows/openclaw-release-checks.yml";
const RELEASE_PUBLISH_WORKFLOW = ".github/workflows/openclaw-release-publish.yml";
const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml";
const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml";
const QA_LIVE_TRANSPORTS_WORKFLOW = ".github/workflows/qa-live-transports-convex.yml";
const UPDATE_MIGRATION_WORKFLOW = ".github/workflows/update-migration.yml";
@@ -1503,7 +1504,7 @@ describe("package artifact reuse", () => {
const npmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8");
const fullReleaseWorkflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8");
expect(workflow).toContain("timeout-minutes: 60");
expect(workflow).toContain("timeout-minutes: 120");
expect(workflow).toContain("environment: npm-release");
expect(workflow).toContain("Download OpenClaw npm preflight manifest");
expect(workflow).toContain("Validate OpenClaw npm preflight manifest");
@@ -1548,6 +1549,143 @@ describe("package artifact reuse", () => {
expect(workflow).not.toContain("timeout-minutes: 360");
});
it("gates stable GitHub publication on the Windows Hub release asset contract", () => {
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
const windowsWorkflow = readFileSync(WINDOWS_NODE_RELEASE_WORKFLOW, "utf8");
const releaseDocs = readFileSync("docs/reference/RELEASING.md", "utf8");
const releaseSkill = readFileSync(
".agents/skills/release-openclaw-maintainer/SKILL.md",
"utf8",
);
expect(releaseWorkflow).toContain(
"Stable OpenClaw publish requires an explicit windows_node_tag.",
);
expect(releaseWorkflow).toContain(
"Stable OpenClaw publish requires candidate-approved windows_node_installer_digests.",
);
expect(releaseWorkflow).toContain("promote_windows_release_assets()");
expect(releaseWorkflow).toContain("dispatch_workflow windows-node-release.yml");
expect(releaseWorkflow).toContain("verify_windows_release_asset_contract");
expect(releaseWorkflow).toContain("Validate stable Windows source release");
expect(releaseWorkflow).toContain("id: windows_source");
expect(releaseWorkflow).toContain(
"windows_node_installer_digests: ${{ steps.windows_source.outputs.installer_digests }}",
);
expect(releaseWorkflow).toContain(
"APPROVED_INSTALLER_DIGESTS: ${{ inputs.windows_node_installer_digests }}",
);
expect(releaseWorkflow).toContain("no longer matches its candidate-approved digest");
expect(releaseWorkflow).toContain(
"WINDOWS_NODE_INSTALLER_DIGESTS: ${{ needs.resolve_release_target.outputs.windows_node_installer_digests }}",
);
expect(releaseWorkflow).toContain(
'-f expected_installer_digests="${WINDOWS_NODE_INSTALLER_DIGESTS}"',
);
expect(releaseWorkflow).toContain("missing prevalidated Windows installer digests");
expect(releaseWorkflow).toContain("does not match its pinned digest");
expect(releaseWorkflow).toContain(
"Stable release OpenClawCompanion asset names do not exactly match the current contract",
);
expect(releaseWorkflow).toContain('select(.name | startswith("OpenClawCompanion-"))');
expect(releaseWorkflow).toContain(
"Windows checksum manifest does not exactly match the installer asset contract",
);
expect(releaseWorkflow).toContain("Windows checksum manifest contains malformed entries");
expect(releaseWorkflow).toContain("([.[].name] | unique | length) == length");
expect(releaseWorkflow).toContain("Windows checksum manifest does not match pinned digest");
expect(releaseWorkflow).toContain(
"Windows source release ${WINDOWS_NODE_TAG} must contain exactly one required asset",
);
expect(releaseWorkflow.indexOf("Validate stable Windows source release")).toBeLessThan(
releaseWorkflow.indexOf("\n publish:\n"),
);
const createDraftCall = releaseWorkflow.lastIndexOf(
"\n create_or_update_github_release\n",
);
const promoteWindowsCall = releaseWorkflow.lastIndexOf(
"\n if ! promote_windows_release_assets; then\n",
);
const publishReleaseCall = releaseWorkflow.lastIndexOf(
"\n publish_github_release\n",
);
expect(createDraftCall).toBeGreaterThan(-1);
expect(promoteWindowsCall).toBeGreaterThan(createDraftCall);
expect(publishReleaseCall).toBeGreaterThan(promoteWindowsCall);
expect(windowsWorkflow).not.toContain("default: latest");
expect(windowsWorkflow).toContain("expected_installer_digests:");
expect(windowsWorkflow).toContain("expected_installer_digests must contain exactly");
expect(windowsWorkflow).toContain("must be an explicit openclaw-windows-node release tag");
expect(windowsWorkflow).toContain("$installerPatterns = @(");
expect(windowsWorkflow).toContain("Every matched installer is signature-checked");
expect(windowsWorkflow).toContain("Get-ChildItem -LiteralPath dist -File");
expect(windowsWorkflow).toContain(
"Downloaded Windows source asset does not match pinned digest",
);
expect(windowsWorkflow).toContain(
"--repo openclaw/openclaw-windows-node --json tagName,isDraft,isPrerelease,assets,url",
);
expect(windowsWorkflow).toContain(
"Windows source release must contain exactly one required asset",
);
expect(windowsWorkflow).toContain(
"Windows source release asset digest does not match the pinned digest",
);
expect(windowsWorkflow).toContain(
"CN=OpenClaw Foundation, O=OpenClaw Foundation, L=Mill Valley, S=California, C=US",
);
expect(windowsWorkflow).toContain("has unexpected signer subject");
expect(windowsWorkflow).toContain("OpenClawCompanion-SHA256SUMS.txt");
expect(windowsWorkflow).toContain("Verify promoted release asset contract");
expect(windowsWorkflow).toContain(
"Promoted OpenClawCompanion asset names do not exactly match the current contract",
);
expect(windowsWorkflow).toContain(
"$targetRelease = gh release view $env:RELEASE_TAG --repo $env:GITHUB_REPOSITORY --json assets",
);
expect(windowsWorkflow).toContain("Promoted Windows SHA-256 manifest does not match");
expect(windowsWorkflow).toContain("Promoted Windows release asset checksum mismatch");
expect(releaseDocs).toContain(
"the selected `windows_node_tag`, its saved `windows_node_installer_digests`,",
);
expect(releaseDocs).toContain(
"candidate-approved `windows_node_installer_digests`, and verify the canonical",
);
expect(releaseSkill).toContain(
"candidate-approved installer digest map as `windows_node_installer_digests`.",
);
});
it("rejects malformed Windows checksum manifest lines before parsing entries", () => {
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
const validateManifestLinesIndex = releaseWorkflow.indexOf("all(.[]; test(");
const parseManifestLinesIndex = releaseWorkflow.indexOf("map(capture(");
expect(validateManifestLinesIndex).toBeGreaterThan(-1);
expect(parseManifestLinesIndex).toBeGreaterThan(validateManifestLinesIndex);
expect(releaseWorkflow).toContain('else error("malformed Windows checksum manifest entry")');
});
it("rejects unsafe direct Windows recovery before uploading assets", () => {
const windowsWorkflow = readFileSync(WINDOWS_NODE_RELEASE_WORKFLOW, "utf8");
const classifyStableReleaseIndex = windowsWorkflow.indexOf("$stableRelease = -not (");
const rejectPrereleaseSourceIndex = windowsWorkflow.indexOf(
"if ($stableRelease -and $sourceRelease.isPrerelease)",
);
const rejectUnexpectedTargetAssetsIndex = windowsWorkflow.indexOf(
"Target OpenClaw release contains unexpected OpenClawCompanion assets before upload",
);
const uploadAssetsIndex = windowsWorkflow.indexOf("gh release upload $env:RELEASE_TAG");
expect(classifyStableReleaseIndex).toBeGreaterThan(-1);
expect(rejectPrereleaseSourceIndex).toBeGreaterThan(classifyStableReleaseIndex);
expect(windowsWorkflow).not.toContain("-not $targetRelease.isPrerelease");
expect(rejectUnexpectedTargetAssetsIndex).toBeGreaterThan(-1);
expect(uploadAssetsIndex).toBeGreaterThan(rejectUnexpectedTargetAssetsIndex);
});
it("keeps beta release verification and ClawHub publish repair hooks wired", () => {
const packageJson = JSON.parse(readFileSync("package.json", "utf8")) as {
scripts?: Record<string, string>;
@@ -13,6 +13,7 @@ import {
import {
freshLaneTimeoutMs,
NpmUpdateSmoke,
parseArgs,
spawnLoggedCommand,
} from "../../scripts/e2e/parallels/npm-update-smoke.ts";
import type { HostServer, Platform } from "../../scripts/e2e/parallels/types.ts";
@@ -70,6 +71,17 @@ afterEach(() => {
});
describe("parallels npm update smoke", () => {
it("accepts one prepared tarball target for update and fresh install", () => {
expect(parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz"])).toMatchObject({
targetTarball: "/tmp/openclaw-candidate.tgz",
updateTarget: "",
freshTargetSpec: undefined,
});
expect(() =>
parseArgs(["--target-tarball", "/tmp/openclaw-candidate.tgz", "--update-target", "beta"]),
).toThrow("--target-tarball cannot be combined");
});
it("stops the host artifact server when the wrapper fails mid-run", async () => {
let stopCalls = 0;
const server: HostServer = {
@@ -120,6 +132,18 @@ describe("parallels npm update smoke", () => {
expect(script).toContain("freshTargetStatus");
});
it("host-serves a prepared candidate tarball for both proof phases", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
expect(script).toContain("--target-tarball <path>");
expect(script).toContain('label: "prepared candidate tgz"');
expect(script).toContain("await copyFile(this.targetTarballPath, hostedTarballPath)");
expect(script).toContain("dir: this.tgzDir");
expect(script).toContain("this.updateTargetEffective = targetUrl");
expect(script).toContain("this.freshTargetSpec = targetUrl");
expect(script).toContain("this.updateExpectedNeedle = this.targetTarballVersion");
});
it("guards beta validation against cross-version harness checkouts", () => {
const script = readFileSync(SCRIPT_PATH, "utf8");
@@ -2,13 +2,57 @@
import { describe, expect, it, vi } from "vitest";
import {
buildPublishCommand,
candidateParallelsArgs,
candidateParallelsShellCommand,
githubApi,
parseArgs,
parseRunIdFromDispatchOutput,
resolveArtifactName,
validateWindowsSourceRelease,
} from "../../scripts/release-candidate-checklist.mjs";
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",
@@ -69,6 +113,139 @@ describe("release candidate checklist", () => {
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 () => ({
ok: true,
json: async () => ({
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 () => ({
ok: true,
json: async () => ({
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", () => {