mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(release): support evidence-backed late closeout
This commit is contained in:
@@ -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" \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user