fix(release): support evidence-backed late closeout

This commit is contained in:
Peter Steinberger
2026-07-13 23:58:09 +01:00
parent 94babf15c3
commit 34c63c3c3a
9 changed files with 478 additions and 14 deletions
@@ -17,6 +17,11 @@ on:
description: UTC date of the private rollback drill in YYYY-MM-DD form; must be within 90 days
required: false
type: string
allow_failed_publish_recovery:
description: Accept a failed Release Publish parent only after every stable platform asset was repaired and published
required: false
default: false
type: boolean
permissions:
actions: read
@@ -295,6 +300,11 @@ jobs:
echo "Stable closeout manifest for $tag does not match immutable postpublish evidence; refusing to accept it." >&2
exit 1
fi
if [[ "$EVENT_NAME" == "push" && -f "$closeout_checksum_path" ]]; then
echo "Stable closeout already complete for $tag."
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [[ -z "$ROLLBACK_DRILL_ID" || -z "$ROLLBACK_DRILL_DATE" ]]; then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "::warning::Stable closeout skipped: rollback drill repository variables are missing; manual dispatch remains required to complete closeout."
@@ -391,6 +401,7 @@ jobs:
FULL_RELEASE_VALIDATION_RUN_ID: ${{ needs.resolve.outputs.full_release_validation_run_id }}
FULL_RELEASE_VALIDATION_RUN_ATTEMPT: ${{ needs.resolve.outputs.full_release_validation_run_attempt }}
RELEASE_PUBLISH_RUN_ID: ${{ needs.resolve.outputs.release_publish_run_id }}
ALLOW_FAILED_PUBLISH_RECOVERY: ${{ github.event_name == 'workflow_dispatch' && inputs.allow_failed_publish_recovery && 'true' || 'false' }}
run: |
set -euo pipefail
. "$RUNNER_TEMP/github-api-backoff.sh"
@@ -414,7 +425,7 @@ jobs:
}
NODE
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion \
--json workflowName,event,status,conclusion,headSha \
> "$RUNNER_TEMP/release-publish-run.json"
node --input-type=module - "$RUNNER_TEMP/release-publish-run.json" <<'NODE'
import { readFileSync } from "node:fs";
@@ -423,14 +434,124 @@ jobs:
["workflowName", "OpenClaw Release Publish"],
["event", "workflow_dispatch"],
["status", "completed"],
["conclusion", "success"],
]) {
if (run[key] !== expected) {
throw new Error(`OpenClaw Release Publish must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`);
}
}
const recoveryRequested = process.env.ALLOW_FAILED_PUBLISH_RECOVERY === "true";
if (recoveryRequested && run.conclusion !== "failure") {
throw new Error(
`Failed-publish recovery requires conclusion=failure, got ${run.conclusion ?? "<missing>"}.`,
);
}
const failedRecovery = recoveryRequested && run.conclusion === "failure";
if (run.conclusion !== "success" && !failedRecovery) {
throw new Error(
`OpenClaw Release Publish must have conclusion=success, got ${run.conclusion ?? "<missing>"}.`,
);
}
if (failedRecovery) {
console.log("Accepting failed Release Publish parent under explicit complete-platform recovery.");
}
NODE
if [[ "$ALLOW_FAILED_PUBLISH_RECOVERY" == "true" ]]; then
parent_log="$RUNNER_TEMP/release-publish-run.log"
gh_with_retry run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --log \
> "$parent_log"
mapfile -t windows_node_run_ids < <(
sed -nE '/Dispatched windows-node-release\.yml/ { s#.*https://github\.com/openclaw/openclaw/actions/runs/([1-9][0-9]*).*#\1#p; }' "$parent_log" |
LC_ALL=C sort -u
)
if [[ "${#windows_node_run_ids[@]}" != "1" ]]; then
echo "Failed-publish recovery requires exactly one Windows Node Release run dispatched by the parent; found ${#windows_node_run_ids[@]}." >&2
exit 1
fi
windows_node_run_id="${windows_node_run_ids[0]}"
windows_node_run_json="$RUNNER_TEMP/windows-node-release-run.json"
gh_with_retry run view "$windows_node_run_id" --repo "$GITHUB_REPOSITORY" \
--json workflowName,event,status,conclusion,headSha,url,jobs \
> "$windows_node_run_json"
parent_head_sha="$(jq -r '.headSha // empty' "$RUNNER_TEMP/release-publish-run.json")"
PARENT_HEAD_SHA="$parent_head_sha" \
node --input-type=module - "$windows_node_run_json" <<'NODE'
import { readFileSync } from "node:fs";
const run = JSON.parse(readFileSync(process.argv[2], "utf8"));
for (const [key, expected] of [
["workflowName", "Windows Node Release"],
["event", "workflow_dispatch"],
["status", "completed"],
["conclusion", "success"],
["headSha", process.env.PARENT_HEAD_SHA],
]) {
if (run[key] !== expected) {
throw new Error(`Windows Node Release must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`);
}
}
const jobs = (run.jobs ?? []).filter(
(job) => job.name === "Promote signed Windows installers" && job.conclusion === "success",
);
if (jobs.length !== 1) {
throw new Error("Windows Node Release must contain one successful signed-installer promotion job.");
}
for (const name of [
"Validate inputs",
"Verify Authenticode signatures",
"Upload to OpenClaw release",
"Verify promoted release asset contract",
]) {
const steps = (jobs[0].steps ?? []).filter(
(step) => step.name === name && step.conclusion === "success",
);
if (steps.length !== 1) {
throw new Error(`Windows Node Release is missing successful step: ${name}.`);
}
}
NODE
windows_node_log="$RUNNER_TEMP/windows-node-release-run.log"
gh_with_retry run view "$windows_node_run_id" --repo "$GITHUB_REPOSITORY" --log \
> "$windows_node_log"
windows_node_installer_digests="$(node --input-type=module - "$windows_node_log" <<'NODE'
import { readFileSync } from "node:fs";
const log = readFileSync(process.argv[2], "utf8");
const marker = "EXPECTED_INSTALLER_DIGESTS:";
const names = [
"OpenClawCompanion-Setup-arm64.exe",
"OpenClawCompanion-Setup-x64.exe",
];
const contracts = new Set();
for (const line of log.split(/\r?\n/u)) {
const markerIndex = line.indexOf(marker);
if (markerIndex === -1) continue;
const candidate = line.slice(markerIndex + marker.length).trim();
let parsed;
try {
parsed = JSON.parse(candidate);
} catch {
continue;
}
const keys = Object.keys(parsed).toSorted((left, right) => left.localeCompare(right));
if (
JSON.stringify(keys) !== JSON.stringify(names) ||
!names.every((name) => /^sha256:[0-9a-f]{64}$/u.test(parsed[name] ?? ""))
) {
continue;
}
contracts.add(JSON.stringify(Object.fromEntries(names.map((name) => [name, parsed[name]]))));
}
if (contracts.size !== 1) {
throw new Error(`Windows Node Release logs must contain exactly one candidate-approved digest contract, got ${contracts.size}.`);
}
process.stdout.write([...contracts][0]);
NODE
)"
{
echo "WINDOWS_NODE_RELEASE_RUN_ID=$windows_node_run_id"
echo "WINDOWS_NODE_INSTALLER_DIGESTS=$windows_node_installer_digests"
} >> "$GITHUB_ENV"
fi
manifest_dir="$RUNNER_TEMP/full-release-validation-manifest"
rm -rf "$manifest_dir"
mkdir -p "$manifest_dir"
@@ -476,6 +597,7 @@ jobs:
ROLLBACK_DRILL_ID: ${{ needs.resolve.outputs.rollback_drill_id }}
ROLLBACK_DRILL_DATE: ${{ needs.resolve.outputs.rollback_drill_date }}
REPAIR_PARTIAL_CLOSEOUT: ${{ needs.resolve.outputs.repair_partial_closeout }}
ALLOW_FAILED_PUBLISH_RECOVERY: ${{ github.event_name == 'workflow_dispatch' && inputs.allow_failed_publish_recovery && 'true' || 'false' }}
CLOSEOUT_DIR: ${{ runner.temp }}/openclaw-stable-main-closeout
run: |
set -euo pipefail
@@ -484,6 +606,41 @@ jobs:
gh_with_retry release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--json tagName,isDraft,isPrerelease,assets \
> "$CLOSEOUT_DIR/github-release.json"
if [[ "$ALLOW_FAILED_PUBLISH_RECOVERY" == "true" ]]; then
recovery_dir="$CLOSEOUT_DIR/platform-recovery"
mkdir -p "$recovery_dir"
gh_with_retry release download "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" \
--pattern OpenClaw-Android.apk \
--pattern OpenClaw-Android-SHA256SUMS.txt \
--pattern OpenClawCompanion-Setup-arm64.exe \
--pattern OpenClawCompanion-Setup-x64.exe \
--pattern OpenClawCompanion-SHA256SUMS.txt \
--dir "$recovery_dir"
(
cd "$recovery_dir"
verify_checksum_manifest() {
local manifest="$1" actual expected
shift
expected="$(printf '%s\n' "$@" | LC_ALL=C sort)"
actual="$(awk 'NF { name=$2; sub(/^\*/, "", name); print name }' "$manifest" | LC_ALL=C sort)"
if [[ "$actual" != "$expected" ]]; then
echo "$manifest must list exactly: $*" >&2
exit 1
fi
sha256sum --strict --check "$manifest"
}
verify_checksum_manifest OpenClaw-Android-SHA256SUMS.txt \
OpenClaw-Android.apk
verify_checksum_manifest OpenClawCompanion-SHA256SUMS.txt \
OpenClawCompanion-Setup-arm64.exe \
OpenClawCompanion-Setup-x64.exe
)
gh_with_retry attestation verify "$recovery_dir/OpenClaw-Android.apk" \
--repo "$GITHUB_REPOSITORY" \
--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/android-release.yml" \
--source-ref "refs/tags/$RELEASE_TAG" \
--deny-self-hosted-runners
fi
node scripts/verify-stable-main-closeout.mjs \
--tag "$RELEASE_TAG" \
--main-dir "$GITHUB_WORKSPACE" \
@@ -495,6 +652,9 @@ jobs:
--rollback-drill-id "$ROLLBACK_DRILL_ID" \
--rollback-drill-date "$ROLLBACK_DRILL_DATE" \
--allow-stale-rollback-drill "$REPAIR_PARTIAL_CLOSEOUT" \
--require-complete-platform-assets "$ALLOW_FAILED_PUBLISH_RECOVERY" \
--windows-node-release-run-id "${WINDOWS_NODE_RELEASE_RUN_ID:-}" \
--windows-node-installer-digests "${WINDOWS_NODE_INSTALLER_DIGESTS:-}" \
--output "$CLOSEOUT_DIR/stable-main-closeout.json"
release_version="${RELEASE_TAG#v}"
sha256sum "$CLOSEOUT_DIR/stable-main-closeout.json" | awk -v asset="openclaw-${release_version}-stable-main-closeout.json" \
+3 -1
View File
@@ -193,7 +193,7 @@ This checklist is the public shape of the release flow. Private credentials, sig
Stable publication is not complete until `main` carries the actual shipped release state.
1. Start from fresh latest `main`. Audit `release/YYYY.M.PATCH` against it and forward-port real fixes absent from `main`. Do not blindly merge release-only compatibility, test, or validation adapters into newer `main`.
2. Set `main` to the shipped stable version, not a speculative next train. Run `pnpm release:prep` after the root version change, then `pnpm deps:shrinkwrap:generate`.
2. For the normal path, set `main` to the shipped stable version. A late closeout may use `main` after it has advanced to a later stable OpenClaw CalVer; do not downgrade an already-started release train solely to close the prior release. The validator still requires the exact shipped changelog section and appcast entry and records the actual `main` version and SHA. Run `pnpm release:prep` after any root version change, then `pnpm deps:shrinkwrap:generate`.
3. Make `CHANGELOG.md`'s `## YYYY.M.PATCH` section on `main` exactly match the tagged release branch. Include the stable `appcast.xml` update when the mac release published one.
4. Do not add `YYYY.M.PATCH+1`, a beta version, or an empty future changelog section to `main` until the operator explicitly starts that release train.
5. Run `pnpm release:generated:check`, `pnpm deps:shrinkwrap:check`, and `OPENCLAW_TESTBOX=1 pnpm check:changed`. Push, then verify `origin/main` contains the shipped version and changelog before calling the stable release done.
@@ -203,6 +203,8 @@ Stable publication is not complete until `main` carries the actual shipped relea
A complete closeout requires both assets and a matching checksum. A partial manifest replays its recorded `main` SHA and rollback drill to regenerate identical bytes, then attaches the missing checksum; an invalid pair, or a checksum without a manifest, stays blocking. A push-triggered run without rollback drill repository variables skips without completing closeout; a missing or more-than-90-day-old drill record still blocks manual evidence-backed closeout. Private recovery commands remain in the maintainer-only runbook. Use manual dispatch only to repair or replay an evidence-backed stable closeout.
If the Release Publish parent failed only after immutable npm/plugin evidence was attached, repair and publish every stable platform asset first. Then a maintainer may manually dispatch closeout with `allow_failed_publish_recovery=true`; that mode accepts only a completed failed parent and additionally requires the exact Android and Windows asset contracts, GitHub SHA-256 digests, checksum verification, Android provenance, and a successful parent-dispatched Windows promotion whose Authenticode checks and candidate-approved digests match the published installers, alongside the normal macOS/appcast checks. Automatic push closeout never enables this recovery mode.
A legacy fallback correction tag may reuse base-package evidence only when the correction tag resolves to the same source commit as the base stable tag. Its Android release reuses the base tag's verified APK and adds provenance for the correction tag. A correction with different source must publish and verify its own package evidence and use a higher Android `versionCode`.
## Release preflight
+131 -6
View File
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
import { escapeRegExp } from "./regexp.mjs";
const STABLE_RELEASE_TAG_RE = /^v(?<version>\d{4}\.\d{1,2}\.\d{1,2})(?:-[1-9]\d*)?$/u;
const STABLE_PACKAGE_VERSION_RE =
/^(?<year>\d{4})\.(?<month>\d{1,2})\.(?<patch>\d{1,2})(?:-(?<correction>[1-9]\d*))?$/u;
const MAX_ROLLBACK_DRILL_AGE_MS = 90 * 24 * 60 * 60 * 1000;
function parseStableReleaseTagDetails(tag) {
@@ -23,6 +25,33 @@ export function parseStableReleaseTag(tag) {
return parseStableReleaseTagDetails(tag).baseVersion;
}
function parseStablePackageVersion(version) {
const match = STABLE_PACKAGE_VERSION_RE.exec(version);
if (!match?.groups) {
return null;
}
return [
Number.parseInt(match.groups.year, 10),
Number.parseInt(match.groups.month, 10),
Number.parseInt(match.groups.patch, 10),
Number.parseInt(match.groups.correction ?? "0", 10),
];
}
function isStableMainVersionAtLeast(mainVersion, shippedVersion) {
const main = parseStablePackageVersion(mainVersion);
const shipped = parseStablePackageVersion(shippedVersion);
if (!main || !shipped) {
return false;
}
for (let index = 0; index < main.length; index += 1) {
if (main[index] !== shipped[index]) {
return main[index] > shipped[index];
}
}
return true;
}
export function extractStableChangelogSection(changelog, version) {
const heading = new RegExp(`^## ${escapeRegExp(version)}\\n`, "mu").exec(changelog);
if (!heading || heading.index === undefined) {
@@ -96,8 +125,7 @@ export function verifyStableMainCloseout(params) {
const errors = [];
const mainVersion = readVersion(params.mainPackageJson, "main", errors);
const tagPackageVersion = readVersion(params.tagPackageJson, "release tag", errors);
const fallbackCorrection =
tagVersion !== baseVersion && mainVersion === baseVersion && tagPackageVersion === baseVersion;
const fallbackCorrection = tagVersion !== baseVersion && tagPackageVersion === baseVersion;
const version = fallbackCorrection ? baseVersion : tagVersion;
const fullReleaseValidationRunAttempt = params.fullReleaseValidationRunAttempt ?? "";
@@ -107,9 +135,9 @@ export function verifyStableMainCloseout(params) {
);
}
if (mainVersion && mainVersion !== version) {
if (mainVersion && !isStableMainVersionAtLeast(mainVersion, version)) {
errors.push(
`main package.json version is ${mainVersion}, expected shipped version ${version}.`,
`main package.json version is ${mainVersion}, expected shipped version ${version} or a later stable OpenClaw CalVer.`,
);
}
if (tagPackageVersion && tagPackageVersion !== version) {
@@ -150,7 +178,9 @@ export function verifyStableMainCloseout(params) {
`OpenClaw-${macAssetVersion}.dmg`,
`OpenClaw-${macAssetVersion}.dSYM.zip`,
];
const assetNames = new Set(readReleaseAssets(params.release).map((asset) => asset.name));
const releaseAssets = readReleaseAssets(params.release);
const assetNames = new Set(releaseAssets.map((asset) => asset.name));
let releasePublishRecovery = null;
const missingMacAssets = expectedMacAssets.filter((asset) => !assetNames.has(asset));
if (missingMacAssets.length > 0) {
errors.push(
@@ -163,6 +193,100 @@ export function verifyStableMainCloseout(params) {
}
}
if (params.requireCompletePlatformAssets) {
const requiredPlatformFamilies = [
{
label: "Android",
prefix: "OpenClaw-Android",
expected: ["OpenClaw-Android-SHA256SUMS.txt", "OpenClaw-Android.apk"],
},
{
label: "Windows",
prefix: "OpenClawCompanion-",
expected: [
"OpenClawCompanion-SHA256SUMS.txt",
"OpenClawCompanion-Setup-arm64.exe",
"OpenClawCompanion-Setup-x64.exe",
],
},
];
for (const family of requiredPlatformFamilies) {
const compareNames = (left, right) => left.localeCompare(right);
const actual = [...assetNames]
.filter((name) => name.startsWith(family.prefix))
.toSorted(compareNames);
const expected = family.expected.toSorted(compareNames);
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
errors.push(
`GitHub release ${params.tag} ${family.label} asset names do not match the recovery contract: expected ${family.expected.join(", ")}; got ${actual.join(", ") || "<none>"}.`,
);
}
const invalidDigests = family.expected.filter((name) => {
const asset = releaseAssets.find((candidate) => candidate.name === name);
return !/^sha256:[0-9a-f]{64}$/u.test(asset?.digest ?? "");
});
if (invalidDigests.length > 0) {
errors.push(
`GitHub release ${params.tag} ${family.label} recovery asset(s) lack GitHub SHA-256 digests: ${invalidDigests.join(", ")}.`,
);
}
}
const windowsInstallerNames = [
"OpenClawCompanion-Setup-arm64.exe",
"OpenClawCompanion-Setup-x64.exe",
];
let trustedWindowsDigests = params.windowsNodeInstallerDigests;
if (typeof trustedWindowsDigests === "string") {
try {
trustedWindowsDigests = JSON.parse(trustedWindowsDigests);
} catch {
trustedWindowsDigests = null;
}
}
const trustedDigestNames =
trustedWindowsDigests &&
typeof trustedWindowsDigests === "object" &&
!Array.isArray(trustedWindowsDigests)
? Object.keys(trustedWindowsDigests).toSorted((left, right) => left.localeCompare(right))
: [];
const expectedDigestNames = windowsInstallerNames.toSorted((left, right) =>
left.localeCompare(right),
);
const trustedDigestContractValid =
JSON.stringify(trustedDigestNames) === JSON.stringify(expectedDigestNames) &&
windowsInstallerNames.every((name) =>
/^sha256:[0-9a-f]{64}$/u.test(trustedWindowsDigests?.[name] ?? ""),
);
if (!trustedDigestContractValid) {
errors.push(
"failed-publish recovery is missing the exact candidate-approved Windows installer digests.",
);
} else {
const mismatchedWindowsAssets = windowsInstallerNames.filter((name) => {
const asset = releaseAssets.find((candidate) => candidate.name === name);
return asset?.digest !== trustedWindowsDigests[name];
});
if (mismatchedWindowsAssets.length > 0) {
errors.push(
`GitHub release ${params.tag} Windows recovery asset(s) do not match candidate-approved digests: ${mismatchedWindowsAssets.join(", ")}.`,
);
}
}
if (!/^[1-9]\d*$/u.test(params.windowsNodeReleaseRunId ?? "")) {
errors.push("failed-publish recovery is missing a trusted Windows Node Release run id.");
}
if (trustedDigestContractValid && /^[1-9]\d*$/u.test(params.windowsNodeReleaseRunId ?? "")) {
releasePublishRecovery = {
completePlatformAssetsRequired: true,
windowsNodeReleaseRunId: params.windowsNodeReleaseRunId,
windowsNodeInstallerDigests: Object.fromEntries(
windowsInstallerNames.map((name) => [name, trustedWindowsDigests[name]]),
),
};
}
}
verifyRollbackDrill(params, errors);
if (errors.length > 0) {
@@ -184,11 +308,12 @@ export function verifyStableMainCloseout(params) {
fullReleaseValidationRunId: params.fullReleaseValidationRunId,
fullReleaseValidationRunAttempt,
releasePublishRunId: params.releasePublishRunId,
...(releasePublishRecovery ? { releasePublishRecovery } : {}),
rollbackDrill: {
id: params.rollbackDrillId,
date: params.rollbackDrillDate,
},
githubReleaseAssets: readReleaseAssets(params.release)
githubReleaseAssets: releaseAssets
.filter((asset) => !isCloseoutEvidenceAsset(asset.name, params.tag))
.map((asset) => ({
name: asset.name,
+41 -1
View File
@@ -7,12 +7,35 @@ FEED_URL=${2:-"https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.
PRIVATE_KEY_FILE=${SPARKLE_PRIVATE_KEY_FILE:-}
find_generate_appcast() {
if [[ -n "${SPARKLE_GENERATE_APPCAST:-}" ]]; then
if [[ ! -x "$SPARKLE_GENERATE_APPCAST" ]]; then
echo "SPARKLE_GENERATE_APPCAST is not executable: $SPARKLE_GENERATE_APPCAST" >&2
return 1
fi
printf '%s\n' "$SPARKLE_GENERATE_APPCAST"
return 0
fi
local host_arch bundled_root bundled_tool
host_arch="$(uname -m)"
bundled_root="$ROOT/apps/macos/.build/$host_arch"
if [[ -d "$bundled_root" ]]; then
bundled_tool="$(find "$bundled_root" -type f -path "*/artifacts/sparkle/Sparkle/bin/generate_appcast" -print -quit)"
if [[ -n "$bundled_tool" ]]; then
printf '%s\n' "$bundled_tool"
return 0
fi
fi
if command -v generate_appcast >/dev/null 2>&1; then
command -v generate_appcast
return 0
fi
find "$ROOT/apps/macos/.build" -type f -path "*/artifacts/sparkle/Sparkle/bin/generate_appcast" -print -quit 2>/dev/null
if [[ -d "$ROOT/apps/macos/.build" ]]; then
find "$ROOT/apps/macos/.build" -type f -path "*/artifacts/sparkle/Sparkle/bin/generate_appcast" -print -quit
fi
return 0
}
if [[ -z "$PRIVATE_KEY_FILE" ]]; then
@@ -86,6 +109,23 @@ fi
"${CHANNEL_ARGS[@]}" \
"$TMP_DIR"
APPCAST_PATH="$TMP_DIR/appcast.xml" APPCAST_VERSION="$VERSION" node <<'NODE'
const { readFileSync } = require("node:fs");
const appcastPath = process.env.APPCAST_PATH;
const version = process.env.APPCAST_VERSION;
const appcast = readFileSync(appcastPath, "utf8");
const item = [...appcast.matchAll(/<item(?:\s[^>]*)?>([\s\S]*?)<\/item>/gu)].find((match) =>
match[1]?.includes(`<sparkle:shortVersionString>${version}</sparkle:shortVersionString>`),
);
if (!item) {
throw new Error(`Generated appcast is missing release ${version}.`);
}
if (!/sparkle:edSignature="[^"]+"/u.test(item[1] ?? "")) {
throw new Error(`Generated appcast release ${version} is missing sparkle:edSignature.`);
}
NODE
cp -f "$TMP_DIR/appcast.xml" "$ROOT/appcast.xml"
echo "Appcast generated (appcast.xml). Upload alongside $ZIP at $FEED_URL"
+3
View File
@@ -70,6 +70,9 @@ function main() {
rollbackDrillId: args["rollback-drill-id"],
rollbackDrillDate: args["rollback-drill-date"],
allowStaleRollbackDrill: args["allow-stale-rollback-drill"] === "true",
requireCompletePlatformAssets: args["require-complete-platform-assets"] === "true",
windowsNodeReleaseRunId: args["windows-node-release-run-id"],
windowsNodeInstallerDigests: args["windows-node-installer-digests"],
nowMs: Date.now(),
});
if (result.errors.length > 0 || !result.manifest) {
+1
View File
@@ -60,6 +60,7 @@ describe("appcast.xml", () => {
throw new Error(`Appcast entry missing version fields: ${item.raw}`);
}
expect(item.sparkleVersion).toBe(canonicalSparkleBuildFromVersion(item.shortVersion));
expect(item.raw).toMatch(/sparkle:edSignature="[^"]+"/u);
}
});
+12
View File
@@ -30,4 +30,16 @@ describe("make_appcast cleanup", () => {
expect(script).toContain('if [[ "$VERSION" == *-alpha.* || "$VERSION" == *.alpha.* ]]; then');
expect(script).toContain('"${CHANNEL_ARGS[@]}"');
});
it("prefers the host-architecture Sparkle tool and requires a signed entry", () => {
const script = readFileSync(scriptPath, "utf8");
expect(script).toContain('if [[ -n "${SPARKLE_GENERATE_APPCAST:-}" ]]');
expect(script).toContain('"$ROOT/apps/macos/.build/$host_arch"');
expect(script).toContain('if [[ -d "$bundled_root" ]]');
expect(script.indexOf('"$ROOT/apps/macos/.build/$host_arch"')).toBeLessThan(
script.indexOf("command -v generate_appcast"),
);
expect(script).toContain("is missing sparkle:edSignature");
});
});
@@ -494,6 +494,32 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain(
"Stable closeout manifest for $tag does not match immutable postpublish evidence; refusing to accept it.",
);
expect(workflow).toContain("Stable closeout already complete for $tag.");
expect(workflow).toContain("allow_failed_publish_recovery:");
expect(workflow).toContain(
'const recoveryRequested = process.env.ALLOW_FAILED_PUBLISH_RECOVERY === "true";',
);
expect(workflow).toContain("Failed-publish recovery requires conclusion=failure");
expect(workflow).toContain(
'--require-complete-platform-assets "$ALLOW_FAILED_PUBLISH_RECOVERY"',
);
expect(workflow).toContain("verify_checksum_manifest OpenClaw-Android-SHA256SUMS.txt");
expect(workflow).toContain("verify_checksum_manifest OpenClawCompanion-SHA256SUMS.txt");
expect(workflow).toContain("actual=\"$(awk 'NF { name=$2;");
expect(workflow).toContain('sub(/^\\*/, "", name)');
expect(workflow).not.toContain('sub(/^\\\\*/, "", name)');
expect(workflow).toContain(
"Windows Node Release must contain one successful signed-installer promotion job.",
);
expect(workflow).toContain('"Verify Authenticode signatures"');
expect(workflow).toContain("EXPECTED_INSTALLER_DIGESTS:");
expect(workflow).toContain('--windows-node-release-run-id "${WINDOWS_NODE_RELEASE_RUN_ID:-}"');
expect(workflow).toContain(
'--windows-node-installer-digests "${WINDOWS_NODE_INSTALLER_DIGESTS:-}"',
);
expect(workflow).toContain(
'--signer-workflow "$GITHUB_REPOSITORY/.github/workflows/android-release.yml"',
);
expect(workflow).toContain(
"Stable closeout requires repository variables RELEASE_ROLLBACK_DRILL_ID and RELEASE_ROLLBACK_DRILL_DATE, or explicit manual overrides.",
);
+99 -4
View File
@@ -68,6 +68,21 @@ describe("stable release closeout", () => {
expect(result.manifest).not.toHaveProperty("verifiedAt");
});
it("accepts closeout after main advances to a later stable CalVer", () => {
const result = verifyStableMainCloseout({
...validCloseoutParams,
mainPackageJson: { version: "2026.7.1" },
nowMs: Date.parse("2026-06-17T00:00:00Z"),
});
expect(result.errors).toEqual([]);
expect(result.manifest).toMatchObject({
releaseVersion: "2026.6.8",
mainPackageVersion: "2026.7.1",
releaseTagPackageVersion: "2026.6.8",
});
});
it("requires an exact Full Release Validation run attempt", () => {
const result = verifyStableMainCloseout({
...validCloseoutParams,
@@ -172,6 +187,7 @@ describe("stable release closeout", () => {
const result = verifyStableMainCloseout({
...validCloseoutParams,
tag: "v2026.6.8-2",
mainPackageJson: { version: "2026.6.9" },
release: {
...release,
tagName: "v2026.6.8-2",
@@ -184,11 +200,78 @@ describe("stable release closeout", () => {
expect(result.errors).toEqual([]);
expect(result.manifest).toMatchObject({
releaseVersion: "2026.6.8",
mainPackageVersion: "2026.6.8",
mainPackageVersion: "2026.6.9",
releaseTagPackageVersion: "2026.6.8",
});
});
it("requires complete platform assets for failed-publish recovery", () => {
const missing = verifyStableMainCloseout({
...validCloseoutParams,
requireCompletePlatformAssets: true,
nowMs: Date.parse("2026-06-17T00:00:00Z"),
});
expect(missing.errors).toContain(
"GitHub release v2026.6.8 Android asset names do not match the recovery contract: expected OpenClaw-Android-SHA256SUMS.txt, OpenClaw-Android.apk; got <none>.",
);
expect(missing.errors).toContain(
"GitHub release v2026.6.8 Windows asset names do not match the recovery contract: expected OpenClawCompanion-SHA256SUMS.txt, OpenClawCompanion-Setup-arm64.exe, OpenClawCompanion-Setup-x64.exe; got <none>.",
);
expect(missing.errors).toContain(
"GitHub release v2026.6.8 Android recovery asset(s) lack GitHub SHA-256 digests: OpenClaw-Android-SHA256SUMS.txt, OpenClaw-Android.apk.",
);
expect(missing.errors).toContain(
"failed-publish recovery is missing the exact candidate-approved Windows installer digests.",
);
expect(missing.errors).toContain(
"failed-publish recovery is missing a trusted Windows Node Release run id.",
);
const complete = verifyStableMainCloseout({
...validCloseoutParams,
requireCompletePlatformAssets: true,
windowsNodeReleaseRunId: "42",
windowsNodeInstallerDigests: {
"OpenClawCompanion-Setup-arm64.exe": `sha256:${"1".repeat(64)}`,
"OpenClawCompanion-Setup-x64.exe": `sha256:${"2".repeat(64)}`,
},
release: {
...release,
assets: [
...release.assets,
{ name: "OpenClaw-Android-SHA256SUMS.txt", digest: `sha256:${"d".repeat(64)}` },
{ name: "OpenClaw-Android.apk", digest: `sha256:${"e".repeat(64)}` },
{
name: "OpenClawCompanion-SHA256SUMS.txt",
digest: `sha256:${"f".repeat(64)}`,
},
{
name: "OpenClawCompanion-Setup-arm64.exe",
digest: `sha256:${"1".repeat(64)}`,
},
{
name: "OpenClawCompanion-Setup-x64.exe",
digest: `sha256:${"2".repeat(64)}`,
},
],
},
nowMs: Date.parse("2026-06-17T00:00:00Z"),
});
expect(complete.errors).toEqual([]);
expect(complete.manifest).toMatchObject({
releasePublishRecovery: {
completePlatformAssetsRequired: true,
windowsNodeReleaseRunId: "42",
windowsNodeInstallerDigests: {
"OpenClawCompanion-Setup-arm64.exe": `sha256:${"1".repeat(64)}`,
"OpenClawCompanion-Setup-x64.exe": `sha256:${"2".repeat(64)}`,
},
},
});
});
it("rejects calendar-normalized rollback drill dates", () => {
const result = verifyStableMainCloseout({
...validCloseoutParams,
@@ -199,10 +282,10 @@ describe("stable release closeout", () => {
expect(result.errors).toContain("rollback drill date is invalid: 2026-02-31.");
});
it("rejects speculative main state, appcast drift, and stale rollback drills", () => {
it("rejects older main state, appcast drift, and stale rollback drills", () => {
const result = verifyStableMainCloseout({
...validCloseoutParams,
mainPackageJson: { version: "2026.6.9" },
mainPackageJson: { version: "2026.6.7" },
mainChangelog: changelog.replace("Shipped fix.", "Different fix."),
mainAppcast: "https://example.test/old.zip\n",
rollbackDrillId: "rollback-drill-2026-q1",
@@ -211,7 +294,7 @@ describe("stable release closeout", () => {
});
expect(result.errors).toContain(
"main package.json version is 2026.6.9, expected shipped version 2026.6.8.",
"main package.json version is 2026.6.7, expected shipped version 2026.6.8 or a later stable OpenClaw CalVer.",
);
expect(result.errors).toContain(
"main CHANGELOG.md ## 2026.6.8 does not exactly match the shipped release section.",
@@ -223,4 +306,16 @@ describe("stable release closeout", () => {
"rollback drill is older than 90 days: 2026-03-01. Run the private rollback drill before stable closeout.",
);
});
it("rejects prerelease main state", () => {
const result = verifyStableMainCloseout({
...validCloseoutParams,
mainPackageJson: { version: "2026.6.9-beta.1" },
nowMs: Date.parse("2026-06-17T00:00:00Z"),
});
expect(result.errors).toContain(
"main package.json version is 2026.6.9-beta.1, expected shipped version 2026.6.8 or a later stable OpenClaw CalVer.",
);
});
});