ci(release): streamline beta publish verification

This commit is contained in:
Peter Steinberger
2026-05-21 07:34:21 +01:00
parent a329b9e1ee
commit 1c5fda115f
7 changed files with 426 additions and 38 deletions

View File

@@ -20,6 +20,10 @@ on:
description: Successful Full Release Validation run id for this tag/SHA, required for real publish
required: false
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
npm_dist_tag:
description: npm dist-tag to publish to
required: true
@@ -333,6 +337,16 @@ jobs:
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
node --import tsx scripts/openclaw-npm-prepublish-verify.ts "$TARBALL_PATH" "$PACKAGE_VERSION"
- name: Verify Docker runtime-assets prune path
env:
DOCKER_BUILDKIT: "1"
run: |
set -euo pipefail
timeout --foreground --kill-after=30s 35m docker build \
--target runtime-assets \
--build-arg OPENCLAW_EXTENSIONS="matrix" \
.
- name: Upload dependency release evidence
uses: actions/upload-artifact@v7
with:
@@ -367,6 +381,7 @@ jobs:
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Require trusted workflow ref for publish
@@ -389,6 +404,7 @@ jobs:
env:
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
run: |
set -euo pipefail
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
@@ -399,6 +415,24 @@ jobs:
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
exit 1
fi
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
echo "Real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2
exit 1
fi
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
publish_openclaw_npm:
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.

View File

@@ -348,6 +348,7 @@ jobs:
needs: [resolve_release_target]
runs-on: ubuntu-latest
timeout-minutes: 60
environment: npm-release
steps:
- name: Checkout release SHA
uses: actions/checkout@v6
@@ -450,7 +451,7 @@ jobs:
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
return 0
return 1
fi
approved=0
@@ -462,13 +463,15 @@ jobs:
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
-F "environment_ids[]=${env_id}" \
-f state=approved \
-f comment="Approve release gate from OpenClaw Release Publish wrapper" >/dev/null
-f comment="Approve child release gate after parent release approval" >/dev/null
approved=1
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
if [[ "${approved}" == "1" ]]; then
echo "${workflow}: approved available pending environment gates"
return 0
fi
return 1
}
print_failed_run_summary() {
@@ -511,7 +514,7 @@ jobs:
if [[ "$state" != "$last_state" ]]; then
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
print_pending_deployments "${workflow}" "${run_id}"
approve_pending_deployments "${workflow}" "${run_id}"
approve_pending_deployments "${workflow}" "${run_id}" || true
last_state="$state"
fi
sleep 30
@@ -558,6 +561,85 @@ jobs:
wait_run_pid="$!"
}
wait_for_job_success() {
local workflow="$1"
local run_id="$2"
local job_name="$3"
local jobs_json job_json run_status run_conclusion status conclusion url deadline
deadline=$((SECONDS + 900))
while true; do
jobs_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,jobs)"
run_status="$(printf '%s' "$jobs_json" | jq -r '.status')"
run_conclusion="$(printf '%s' "$jobs_json" | jq -r '.conclusion // ""')"
job_json="$(printf '%s' "$jobs_json" | jq -c --arg name "$job_name" '.jobs[]? | select(.name == $name) | {status, conclusion, url}' | head -n 1)"
if [[ -n "$job_json" ]]; then
status="$(printf '%s' "$job_json" | jq -r '.status')"
conclusion="$(printf '%s' "$job_json" | jq -r '.conclusion // ""')"
url="$(printf '%s' "$job_json" | jq -r '.url // ""')"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then
echo "${workflow} ${job_name} ${conclusion}: ${url}"
echo "- ${workflow} ${job_name}: ${conclusion} (${url})" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
echo "${workflow} ${job_name} failed: ${conclusion} ${url}" >&2
print_failed_run_summary "${run_id}"
return 1
fi
echo "${workflow} ${job_name} still ${status}: ${url}"
elif [[ "$run_status" == "completed" ]]; then
if [[ "$run_conclusion" == "success" ]]; then
echo "${workflow} completed before ${job_name} was needed."
echo "- ${workflow} ${job_name}: not needed" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
echo "${workflow} completed before ${job_name} with ${run_conclusion}." >&2
print_failed_run_summary "${run_id}"
return 1
else
echo "${workflow} waiting for ${job_name} to start: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fi
if (( SECONDS >= deadline )); then
echo "${workflow} ${job_name} did not complete within 15 minutes." >&2
return 1
fi
sleep 10
done
}
approve_child_publish_environment() {
local workflow="$1"
local run_id="$2"
local run_json status conclusion deadline
deadline=$((SECONDS + 900))
while true; do
if approve_pending_deployments "${workflow}" "${run_id}"; then
echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"
status="$(printf '%s' "$run_json" | jq -r '.status')"
conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
echo "${workflow}: completed before child environment approval was needed"
return 0
fi
echo "${workflow}: completed before child environment approval with ${conclusion}" >&2
print_failed_run_summary "${run_id}"
return 1
fi
if (( SECONDS >= deadline )); then
echo "${workflow}: child environment approval was not available within 15 minutes." >&2
print_pending_deployments "${workflow}" "${run_id}"
return 1
fi
sleep 10
done
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -679,12 +761,81 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}"
else
telegram_line="- npm Telegram beta E2E: not supplied"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
else
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_REPO="${GITHUB_REPOSITORY}" \
RELEASE_TARBALL="${tarball}" \
RELEASE_INTEGRITY="${integrity}" \
RELEASE_PUBLISH_RUN_ID="${GITHUB_RUN_ID}" \
PREFLIGHT_RUN_ID="${PREFLIGHT_RUN_ID}" \
FULL_RELEASE_VALIDATION_RUN_ID="${FULL_RELEASE_VALIDATION_RUN_ID}" \
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
CLAWHUB_LINE="${clawhub_line}" \
TELEGRAM_LINE="${telegram_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
const bodyFile = process.env.RELEASE_BODY_FILE;
const notesFile = process.env.RELEASE_NOTES_FILE;
if (!bodyFile || !notesFile) {
throw new Error("Missing release notes file paths.");
}
const body = readFileSync(bodyFile, "utf8").trimEnd();
const section = [
"### Release verification",
"",
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
process.env.CLAWHUB_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
].join("\n");
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`);
NODE
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}"
echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY"
}
{
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- OpenClaw npm publish: starts after plugin npm succeeds"
@@ -698,8 +849,8 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
clawhub_args+=(-f plugins="${PLUGINS}")
@@ -725,6 +876,7 @@ jobs:
-f preflight_only=false \
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
-f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \
-f release_publish_run_id="${GITHUB_RUN_ID}" \
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
else
@@ -739,7 +891,9 @@ jobs:
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
else
echo "- plugin-clawhub-release.yml: not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
openclaw_result=""
@@ -776,6 +930,7 @@ jobs:
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
append_release_proof_to_github_release
fi
if [[ "${failed}" != "0" ]]; then
exit 1

View File

@@ -20,6 +20,10 @@ on:
required: false
default: ""
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
concurrency:
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -196,6 +200,33 @@ jobs:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_clawhub
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
echo "Plugin ClawHub publish must be dispatched by OpenClaw Release Publish after its npm-release gate." >&2
exit 1
fi
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
@@ -289,11 +320,12 @@ jobs:
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, preview_plugin_pack]
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
actions: read
contents: read
id-token: write
strategy:

View File

@@ -32,6 +32,10 @@ on:
description: Comma-separated plugin package names to publish when publish_scope=selected
required: false
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
concurrency:
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -173,6 +177,33 @@ jobs:
exit 1
fi
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_npm
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
echo "Plugin npm publish must be dispatched by OpenClaw Release Publish after its npm-release gate." >&2
exit 1
fi
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
preview_plugin_pack:
needs: preview_plugins_npm
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
@@ -205,11 +236,12 @@ jobs:
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
publish_plugins_npm:
needs: [preview_plugins_npm, preview_plugin_pack]
needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: npm-release
permissions:
actions: read
contents: read
id-token: write
strategy:

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env -S pnpm tsx
import { spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
@@ -169,7 +169,9 @@ export function parseWorkflowRunIdFromOutput(output: string): string | undefined
type WorkflowRunListEntry = {
createdAt?: string;
created_at?: string;
databaseId?: number | string;
id?: number | string;
};
function normalizeRunId(value: unknown): string | undefined {
@@ -188,35 +190,23 @@ export function selectNewestDispatchedRunId(params: {
}): string | undefined {
return params.runs
.filter((entry) => {
const id = normalizeRunId(entry.databaseId);
const id = normalizeRunId(entry.databaseId ?? entry.id);
return id !== undefined && !params.beforeIds.has(id);
})
.toSorted((a, b) => (b.createdAt ?? "").localeCompare(a.createdAt ?? ""))
.map((entry) => normalizeRunId(entry.databaseId))
.toSorted((a, b) =>
(b.createdAt ?? b.created_at ?? "").localeCompare(a.createdAt ?? a.created_at ?? ""),
)
.map((entry) => normalizeRunId(entry.databaseId ?? entry.id))
.find((id): id is string => id !== undefined);
}
function listWorkflowDispatchRuns(repo: string, workflow: string): WorkflowRunListEntry[] {
return JSON.parse(
run(
"gh",
[
"run",
"list",
"--repo",
repo,
"--workflow",
workflow,
"--event",
"workflow_dispatch",
"--limit",
"50",
"--json",
"databaseId,createdAt",
],
{ capture: true },
),
) as WorkflowRunListEntry[];
const encodedWorkflow = encodeURIComponent(workflow);
const response = ghJson(
repo,
`actions/workflows/${encodedWorkflow}/runs?event=workflow_dispatch&per_page=50`,
) as { workflow_runs?: WorkflowRunListEntry[] };
return response.workflow_runs ?? [];
}
async function findDispatchedWorkflowRunId(params: {
@@ -240,7 +230,7 @@ async function findDispatchedWorkflowRunId(params: {
async function dispatchTelegram(options: Options, packageSpec: string): Promise<string> {
const beforeIds = new Set(
listWorkflowDispatchRuns(options.repo, TELEGRAM_BETA_WORKFLOW_FILE)
.map((entry) => normalizeRunId(entry.databaseId))
.map((entry) => normalizeRunId(entry.databaseId ?? entry.id))
.filter((id): id is string => id !== undefined),
);
const output = run(
@@ -341,25 +331,80 @@ function findFile(root: string, basename: string): string {
return "";
}
export function mergeTelegramProofIntoReleaseBody(body: string, telegramLine: string): string {
if (body.includes(telegramLine)) {
return body;
}
const marker = "### Release verification";
const telegramProofPattern = /^- npm Telegram beta E2E: .*$/mu;
if (telegramProofPattern.test(body)) {
return body.replace(telegramProofPattern, telegramLine);
}
if (!body.includes(marker)) {
return `${body.trimEnd()}\n\n${marker}\n\n${telegramLine}\n`;
}
const markerIndex = body.indexOf(marker);
const afterMarkerIndex = markerIndex + marker.length;
const nextHeading = /\n#{1,6} /u.exec(body.slice(afterMarkerIndex));
const insertionIndex = nextHeading === null ? -1 : afterMarkerIndex + nextHeading.index;
if (insertionIndex === -1) {
return `${body.trimEnd()}\n${telegramLine}\n`;
}
return `${body.slice(0, insertionIndex).trimEnd()}\n${telegramLine}\n${body.slice(insertionIndex)}`;
}
function appendTelegramProofToRelease(repo: string, version: string, runId: string): void {
const tag = `v${version}`;
const release = ghJson(repo, `releases/tags/${encodeURIComponent(tag)}`) as {
body?: string;
html_url?: string;
};
const body = release.body ?? "";
const telegramLine = `- npm Telegram beta E2E: https://github.com/${repo}/actions/runs/${runId}`;
const notesFile = path.join(
"/tmp",
`openclaw-${version.replace(/[^a-zA-Z0-9.-]/g, "-")}-release-notes-${process.pid}.md`,
);
const nextBody = mergeTelegramProofIntoReleaseBody(body, telegramLine);
if (nextBody === body) {
return;
}
writeFileSync(notesFile, nextBody);
run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesFile]);
console.log(
`Updated release proof: ${release.html_url ?? `https://github.com/${repo}/releases/tag/${tag}`}`,
);
}
async function main(): Promise<void> {
const options = parseArgs(process.argv.slice(2));
const version = resolveBetaVersion(options.beta);
const packageSpec = `openclaw@${version}`;
console.log(`Resolved beta target: ${packageSpec}`);
let telegramRunId: string | undefined;
if (!options.skipTelegram) {
telegramRunId = await dispatchTelegram(options, packageSpec);
console.log(
`Dispatched Telegram workflow: https://github.com/${options.repo}/actions/runs/${telegramRunId}`,
);
}
if (!options.skipParallels) {
runParallels(options.beta, options.model);
}
if (!options.skipTelegram) {
const runId = await dispatchTelegram(options, packageSpec);
await pollRun(options.repo, runId);
const artifactDir = downloadTelegramArtifact(options.repo, runId);
if (telegramRunId) {
await pollRun(options.repo, telegramRunId);
const artifactDir = downloadTelegramArtifact(options.repo, telegramRunId);
const report = findFile(artifactDir, "telegram-qa-report.md");
if (report && existsSync(report)) {
console.log(`\nTelegram report: ${report}\n`);
console.log(readFileSync(report, "utf8"));
}
appendTelegramProofToRelease(options.repo, version, telegramRunId);
}
}

View File

@@ -885,6 +885,7 @@ describe("package artifact reuse", () => {
const npmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8");
expect(workflow).toContain("timeout-minutes: 60");
expect(workflow).toContain("environment: npm-release");
expect(workflow).toContain("Download OpenClaw npm preflight manifest");
expect(workflow).toContain("Validate OpenClaw npm preflight manifest");
expect(workflow).toContain("Download full release validation manifest");
@@ -897,8 +898,12 @@ describe("package artifact reuse", () => {
expect(npmWorkflow).toContain("preflight-manifest.json");
expect(npmWorkflow).toContain("Verify full release validation run metadata");
expect(npmWorkflow).toContain("Verify full release validation target");
expect(npmWorkflow).toContain("Verify Docker runtime-assets prune path");
expect(npmWorkflow).toContain("--target runtime-assets");
expect(npmWorkflow).toContain("full_release_validation_run_id");
expect(npmWorkflow).toContain("release_publish_run_id");
expect(npmWorkflow).toContain("Real publish requires full_release_validation_run_id");
expect(npmWorkflow).toContain("Real publish requires release_publish_run_id");
expect(npmWorkflow).toContain("tarballSha256");
expect(workflow).toContain("Checkout release SHA");
expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"');
@@ -914,6 +919,8 @@ describe("package artifact reuse", () => {
};
const releaseWorkflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8");
const clawHubWorkflow = readFileSync(".github/workflows/plugin-clawhub-release.yml", "utf8");
const pluginNpmWorkflow = readFileSync(".github/workflows/plugin-npm-release.yml", "utf8");
const openclawNpmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8");
expect(packageJson.scripts?.["release:verify-beta"]).toBe(
"node --import tsx scripts/release-verify-beta.ts",
@@ -934,7 +941,15 @@ describe("package artifact reuse", () => {
expect(releaseWorkflow).toContain("Plugin ClawHub run ID");
expect(releaseWorkflow).toContain("OpenClaw npm run ID");
expect(releaseWorkflow).toContain("npm_telegram_run_id");
expect(releaseWorkflow).toContain("Approve release gate from OpenClaw Release Publish wrapper");
expect(releaseWorkflow).toContain('release_publish_run_id="${GITHUB_RUN_ID}"');
expect(releaseWorkflow).toContain("append_release_proof_to_github_release");
expect(releaseWorkflow).toContain("registry tarball");
expect(releaseWorkflow).toContain("not awaited by this proof");
expect(releaseWorkflow).toContain("wait_for_job_success");
expect(releaseWorkflow).toContain("Validate release publish approval");
expect(releaseWorkflow).toContain('conclusion" == "skipped"');
expect(releaseWorkflow).toContain("approve_child_publish_environment");
expect(releaseWorkflow).toContain("Approve child release gate after parent release approval");
expect(releaseWorkflow).toContain("release:verify-beta");
expect(releaseWorkflow).toContain('--workflow-ref "${CHILD_WORKFLOW_REF}"');
expect(releaseWorkflow).toContain('verify_args+=(--plugins "${PLUGINS}")');
@@ -944,6 +959,18 @@ describe("package artifact reuse", () => {
expect(releaseWorkflow).toContain("Workflow completion does not wait for ClawHub");
expect(releaseWorkflow).toContain('[[ "${WAIT_FOR_CLAWHUB}" == "true" ]]');
expect(releaseWorkflow).toContain("--skip-clawhub");
expect(pluginNpmWorkflow).toContain("Validate release publish approval run");
expect(clawHubWorkflow).toContain("Validate release publish approval run");
expect(openclawNpmWorkflow).toContain("Validate release publish approval run");
expect(pluginNpmWorkflow).toContain('GITHUB_ACTOR}" != "github-actions[bot]"');
expect(clawHubWorkflow).toContain('GITHUB_ACTOR}" != "github-actions[bot]"');
expect(openclawNpmWorkflow).toContain('GITHUB_ACTOR}" != "github-actions[bot]"');
expect(pluginNpmWorkflow).toContain("must still be in_progress");
expect(clawHubWorkflow).toContain("must still be in_progress");
expect(openclawNpmWorkflow).toContain("must still be in_progress");
expect(pluginNpmWorkflow).toContain("environment: npm-release");
expect(clawHubWorkflow).toContain("environment: clawhub-plugin-release");
expect(openclawNpmWorkflow).toContain("environment: npm-release");
expect(releaseWorkflow.lastIndexOf("create_or_update_github_release")).toBeLessThan(
releaseWorkflow.indexOf('if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"'),
);

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
mergeTelegramProofIntoReleaseBody,
parseWorkflowRunIdFromOutput,
selectNewestDispatchedRunId,
} from "../../scripts/release-beta-smoke.ts";
@@ -27,4 +28,66 @@ describe("release-beta-smoke", () => {
}),
).toBe("103");
});
it("selects runs returned by the actions workflow runs API", () => {
const beforeIds = new Set(["200"]);
expect(
selectNewestDispatchedRunId({
beforeIds,
runs: [
{ id: 200, created_at: "2026-05-04T10:00:00Z" },
{ id: 201, created_at: "2026-05-04T10:02:00Z" },
{ id: 202, created_at: "2026-05-04T10:01:00Z" },
],
}),
).toBe("201");
});
it("replaces stale Telegram proof placeholders", () => {
const body = [
"## Changes",
"",
"### Release verification",
"",
"- npm package: https://www.npmjs.com/package/openclaw/v/2026.5.20-beta.1",
"- npm Telegram beta E2E: not supplied",
"",
"### Assets",
"",
"- artifact",
"",
].join("\n");
const merged = mergeTelegramProofIntoReleaseBody(
body,
"- npm Telegram beta E2E: https://github.com/openclaw/openclaw/actions/runs/123",
);
expect(merged).toContain("actions/runs/123");
expect(merged).not.toContain("not supplied");
expect(merged).toContain("### Assets");
});
it("inserts Telegram proof before the next release notes subsection", () => {
const body = [
"## Changes",
"",
"### Release verification",
"",
"- npm package: https://www.npmjs.com/package/openclaw/v/2026.5.20-beta.1",
"",
"### Assets",
"",
"- artifact",
"",
].join("\n");
const merged = mergeTelegramProofIntoReleaseBody(
body,
"- npm Telegram beta E2E: https://github.com/openclaw/openclaw/actions/runs/123",
);
expect(merged.indexOf("actions/runs/123")).toBeLessThan(merged.indexOf("### Assets"));
});
});