mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user