ci: simplify extended-stable release finalization

This commit is contained in:
Dallin Romney
2026-08-13 12:58:58 +08:00
parent 1e005d35f4
commit 8dff2898f6
6 changed files with 100 additions and 132 deletions
+3 -2
View File
@@ -319,8 +319,9 @@ node scripts/full-release-validation-at-sha.mjs \
Extended-stable may use the helper's trusted main-pinned `release-ci/*` harness
or a direct run from `extended-stable/YYYY.M.33` with
`release_profile=stable`. In either case, require a complete v3 manifest that
binds the exact canonical branch tip, workflow SHA, and run attempt. Use
`release_profile=stable`. In either case, require a complete Full Release
Validation evidence manifest using schema version 3. It must bind the exact
canonical branch tip, workflow SHA, and run attempt. Use
`$release-openclaw-ci` for failure classification and identity rules.
The helper verifies and pins the recorded Tooling SHA on trusted `main`, passes
@@ -304,13 +304,13 @@ on pinned current `main` as the exact command and validation contract.
approval-gated `docker-channel-promote.yml` from current `main` with the exact
tag; never rebuild or move the release tag.
10. Dispatch the extended-stable `OpenClaw Release Publish` closeout. It must
reverify the exact npm selector and tarball, render the canonical release
notes, publish Docker, and only then create the public non-prerelease GitHub
Release with `latest=false`. Re-run failed jobs to retry a failed finalizer
without repeating successful Docker work; a fresh dispatch safely repeats
same-version Docker verification and promotion. Do not publish macOS,
Windows, mobile, website, ClawHub, npm `latest`, or private dist-tag
artifacts from this path.
reverify the exact npm selector and tarball, then publish Docker. Only after
Docker succeeds does the finalizer render the canonical release notes and
create the public non-prerelease GitHub Release with `latest=false`. Re-run
failed jobs to retry a failed finalizer without repeating successful Docker
work; a fresh dispatch safely repeats same-version Docker verification and
promotion. Do not publish macOS, Windows, mobile, website, ClawHub, npm
`latest`, or private dist-tag artifacts from this path.
## Keep release channel naming aligned
+38 -66
View File
@@ -2492,8 +2492,8 @@ jobs:
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: error
prepare_extended_stable_release:
name: Verify npm and prepare extended-stable release notes
verify_core_npm_registry:
name: Verify already-published core npm package
needs: [resolve_release_target]
if: ${{ inputs.publish_docker_only }}
runs-on: ubuntu-latest
@@ -2524,60 +2524,10 @@ jobs:
exit 1
fi
- name: Checkout trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
persist-credentials: false
- name: Setup trusted release tooling
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Render canonical extended-stable release notes
env:
RELEASE_TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
run: |
set -euo pipefail
release_version="${RELEASE_TAG#v}"
notes_dir="${RUNNER_TEMP}/extended-stable-release-notes"
changelog_file="${notes_dir}/CHANGELOG.md"
notes_file="${notes_dir}/release-notes.md"
verification_file="${notes_dir}/verification.md"
mkdir -p "${notes_dir}"
git fetch --no-tags --depth=1 origin "${TARGET_SHA}"
git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"
cat > "${verification_file}" <<EOF
### Release verification
- Scope: `openclaw@${release_version}`, official npm plugins, and Gateway container images only.
- Channel: npm and container `extended-stable`; regular `latest` is unchanged.
- Containers: published and verified before this release page became public.
- Excluded: ClawHub, macOS, Windows, mobile, website, and private dist-tags.
EOF
node --import tsx scripts/render-github-release-notes.mts \
--changelog "${changelog_file}" \
--tag "${RELEASE_TAG}" \
--repository "${GITHUB_REPOSITORY}" \
--verification-file "${verification_file}" \
--output "${notes_file}"
- name: Upload canonical extended-stable release notes
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: extended-stable-release-notes-${{ inputs.tag }}
path: ${{ runner.temp }}/extended-stable-release-notes/release-notes.md
if-no-files-found: error
retention-days: 7
publish_docker:
name: Publish Docker images
needs: [resolve_release_target, publish, prepare_extended_stable_release]
if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success')) }}
needs: [resolve_release_target, publish, verify_core_npm_registry]
if: ${{ always() && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.verify_core_npm_registry.result == 'success')) }}
uses: ./.github/workflows/docker-release.yml
with:
tag: ${{ inputs.tag }}
@@ -2632,20 +2582,26 @@ jobs:
finalize_extended_stable_github_release:
name: Publish extended-stable GitHub release
needs: [resolve_release_target, prepare_extended_stable_release, publish_docker]
if: ${{ always() && inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.publish_docker.result == 'success' }}
needs: [resolve_release_target, publish_docker]
if: ${{ always() && inputs.publish_docker_only && needs.publish_docker.result == 'success' }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: write
steps:
- name: Download canonical extended-stable release notes
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v8.0.1
- name: Checkout trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
name: extended-stable-release-notes-${{ inputs.tag }}
path: ${{ runner.temp }}/extended-stable-release-notes
ref: ${{ github.sha }}
fetch-depth: 1
persist-credentials: false
- name: Publish canonical extended-stable release
- name: Setup trusted release tooling
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Render and publish canonical extended-stable release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
@@ -2654,13 +2610,29 @@ jobs:
set -euo pipefail
release_version="${RELEASE_TAG#v}"
release_title="openclaw ${release_version}"
notes_file="${RUNNER_TEMP}/extended-stable-release-notes/release-notes.md"
notes_dir="${RUNNER_TEMP}/extended-stable-release-notes"
changelog_file="${notes_dir}/CHANGELOG.md"
notes_file="${notes_dir}/release-notes.md"
verification_file="${notes_dir}/verification.md"
resource_file="${RUNNER_TEMP}/extended-stable-release.json"
mkdir -p "${notes_dir}"
if [[ ! -s "${notes_file}" ]]; then
echo "Canonical extended-stable release notes artifact is missing or empty." >&2
exit 1
fi
git fetch --no-tags --depth=1 origin "${TARGET_SHA}"
git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"
cat > "${verification_file}" <<EOF
### Release verification
- Scope: `openclaw@${release_version}`, official npm plugins, and Gateway container images only.
- Channel: npm and container `extended-stable`; regular `latest` is unchanged.
- Containers: published and verified before this release page became public.
- Excluded: ClawHub, macOS, Windows, mobile, website, and private dist-tags.
EOF
node --import tsx scripts/render-github-release-notes.mts \
--changelog "${changelog_file}" \
--tag "${RELEASE_TAG}" \
--repository "${GITHUB_REPOSITORY}" \
--verification-file "${verification_file}" \
--output "${notes_file}"
verify_release_tag_target() {
local direct_sha peeled_sha remote_refs remote_sha
+3 -3
View File
@@ -383,9 +383,9 @@ preflight and Full Release Validation evidence as a core publish.
Extended-stable uses this workflow only after npm publication, with
`publish_openclaw_npm=false` and `publish_docker_only=true`. That closeout
rechecks immutable npm evidence and the exact canonical validation branch,
renders canonical release notes, publishes Docker, and creates the public
notes-only non-Latest GitHub Release without entering plugin, ClawHub, or
native-app publication. Re-running failed jobs retries the finalizer without
publishes Docker, and only then renders canonical release notes and creates the
public notes-only non-Latest GitHub Release without entering plugin, ClawHub,
or native-app publication. Re-running failed jobs retries the finalizer without
repeating a successful Docker job; a fresh dispatch repeats the idempotent
same-version Docker verification and promotion.
+6 -6
View File
@@ -203,11 +203,11 @@ After that core registry readback succeeds, start Docker publication only throug
`OpenClaw Release Publish`. Its extended-stable closeout path rechecks the
saved npm preflight artifact, exact `Full Release Validation` evidence, exact npm
version and `extended-stable` selector, and published tarball digest. It then
renders and validates the canonical release notes, calls the reusable
`Docker Release` workflow, and creates the public non-prerelease GitHub Release
with `latest=false` only after Docker succeeds. Re-run failed jobs to retry a
failed release-page finalizer without repeating successful Docker work; a fresh
workflow dispatch safely repeats same-version Docker verification and
calls the reusable `Docker Release` workflow. Only after Docker succeeds does
the finalizer render and validate the canonical release notes and create the
public non-prerelease GitHub Release with `latest=false`. Re-run failed jobs to
retry a failed release-page finalizer without repeating successful Docker work;
a fresh workflow dispatch safely repeats same-version Docker verification and
promotion. A tag push never publishes Docker images or a release page by
itself:
@@ -732,7 +732,7 @@ readback confirms that every exact package and `extended-stable` tag converged.
- `windows_node_installer_digests`: candidate-approved compact JSON map of the current Windows installer names to their pinned `sha256:` digests; required for stable OpenClaw publish
- `npm_telegram_run_id`: optional successful `NPM Telegram Beta E2E` run id to include in final release evidence
- `npm_dist_tag`: npm target tag for the OpenClaw package, one of `alpha`, `beta`, `latest`, or `extended-stable`
- `publish_docker_only`: legacy input name for the extended-stable recovery/closeout path. It requires `publish_openclaw_npm=false`, complete preflight and Full Release Validation evidence, then verifies the exact npm package, selector, and tarball digest, renders canonical notes, publishes Docker, and creates the public notes-only GitHub Release with `latest=false`.
- `publish_docker_only`: legacy input name for the extended-stable recovery/closeout path. It requires `publish_openclaw_npm=false`, complete preflight and Full Release Validation evidence, then verifies the exact npm package, selector, and tarball digest, publishes Docker, and finally renders canonical notes and creates the public notes-only GitHub Release with `latest=false`.
- `plugin_publish_scope`: defaults to `all-publishable`; use `selected` only for focused plugin-only repair work with `publish_openclaw_npm=false`
- `plugins`: comma-separated `@openclaw/*` package names when `plugin_publish_scope=selected`
- `publish_openclaw_npm`: defaults to `true`; set `false` only when using the workflow as a plugin-only repair orchestrator
+43 -48
View File
@@ -1072,10 +1072,10 @@ describe("release validation no-push transport", () => {
expect(dockerCall.needs).toEqual([
"resolve_release_target",
"publish",
"prepare_extended_stable_release",
"verify_core_npm_registry",
]);
expect(dockerCall.if).toContain("needs.publish.result == 'success'");
expect(dockerCall.if).toContain("needs.prepare_extended_stable_release.result == 'success'");
expect(dockerCall.if).toContain("needs.verify_core_npm_registry.result == 'success'");
expect(dockerCall.with).toEqual({
tag: "${{ inputs.tag }}",
release_sha: "${{ needs.resolve_release_target.outputs.sha }}",
@@ -1104,66 +1104,59 @@ describe("release validation no-push transport", () => {
"publish_docker",
]);
const prepareRelease = job(releasePublish, "prepare_extended_stable_release");
const prepareSteps = prepareRelease.steps ?? [];
const prepareStepNames = prepareSteps.map((workflowStep) => workflowStep.name);
const verifyRegistry = job(releasePublish, "verify_core_npm_registry");
const verifyNpm = step(
prepareRelease,
verifyRegistry,
"Verify exact npm and selector readback matches preflight bytes",
);
const renderNotes = step(prepareRelease, "Render canonical extended-stable release notes");
const checkoutIndex = prepareStepNames.indexOf("Checkout trusted release tooling");
const setupIndex = prepareStepNames.indexOf("Setup trusted release tooling");
const renderIndex = prepareStepNames.indexOf("Render canonical extended-stable release notes");
const uploadIndex = prepareStepNames.indexOf("Upload canonical extended-stable release notes");
expect(prepareRelease.needs).toEqual(["resolve_release_target"]);
expect(prepareRelease.if).toBe("${{ inputs.publish_docker_only }}");
expect(prepareRelease.environment).toBeUndefined();
expect(prepareRelease.permissions).toEqual({ contents: "read" });
expect(setupIndex).toBeGreaterThan(checkoutIndex);
expect(renderIndex).toBeGreaterThan(setupIndex);
expect(uploadIndex).toBeGreaterThan(renderIndex);
expect(verifyRegistry.needs).toEqual(["resolve_release_target"]);
expect(verifyRegistry.if).toBe("${{ inputs.publish_docker_only }}");
expect(verifyRegistry.environment).toBeUndefined();
expect(verifyRegistry.permissions).toEqual({ contents: "read" });
expect(verifyRegistry.steps).toHaveLength(1);
expect(verifyNpm.run).toContain('npm view "openclaw@${version}" version');
expect(verifyNpm.run).toContain("Published npm tarball does not match");
expect(step(prepareRelease, "Checkout trusted release tooling").with).toMatchObject({
ref: "${{ github.sha }}",
"persist-credentials": false,
});
expect(step(prepareRelease, "Setup trusted release tooling")).toMatchObject({
uses: "./.github/actions/setup-node-env",
with: { "install-bun": "false" },
});
expect(renderNotes.run).toContain('git fetch --no-tags --depth=1 origin "${TARGET_SHA}"');
expect(renderNotes.run).toContain('git show "${TARGET_SHA}:CHANGELOG.md"');
expect(renderNotes.run).toContain("node --import tsx scripts/render-github-release-notes.mts");
expect(renderNotes.run).not.toContain("render-github-release-notes.mjs");
const uploadNotes = step(prepareRelease, "Upload canonical extended-stable release notes");
expect(uploadNotes.with).toMatchObject({
name: "extended-stable-release-notes-${{ inputs.tag }}",
"if-no-files-found": "error",
});
const finalizeRelease = job(releasePublish, "finalize_extended_stable_github_release");
const downloadNotes = step(finalizeRelease, "Download canonical extended-stable release notes");
const publishRelease = step(finalizeRelease, "Publish canonical extended-stable release");
const finalizeSteps = finalizeRelease.steps ?? [];
const finalizeStepNames = finalizeSteps.map((workflowStep) => workflowStep.name);
const checkoutIndex = finalizeStepNames.indexOf("Checkout trusted release tooling");
const setupIndex = finalizeStepNames.indexOf("Setup trusted release tooling");
const publishIndex = finalizeStepNames.indexOf(
"Render and publish canonical extended-stable release",
);
const publishRelease = step(
finalizeRelease,
"Render and publish canonical extended-stable release",
);
const publishReleaseRun = publishRelease.run ?? "";
expect(finalizeRelease.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"publish_docker",
]);
expect(finalizeRelease.needs).toEqual(["resolve_release_target", "publish_docker"]);
expect(finalizeRelease.if).toContain("inputs.publish_docker_only");
expect(finalizeRelease.if).toContain(
"needs.prepare_extended_stable_release.result == 'success'",
);
expect(finalizeRelease.if).toContain("needs.publish_docker.result == 'success'");
expect(finalizeRelease.environment).toBe("npm-release");
expect(finalizeRelease.permissions).toEqual({ contents: "write" });
expect(downloadNotes.with).toMatchObject({
name: "extended-stable-release-notes-${{ inputs.tag }}",
expect(checkoutIndex).toBeGreaterThan(-1);
expect(setupIndex).toBeGreaterThan(checkoutIndex);
expect(publishIndex).toBeGreaterThan(setupIndex);
expect(step(finalizeRelease, "Checkout trusted release tooling").with).toMatchObject({
ref: "${{ github.sha }}",
"persist-credentials": false,
});
expect(step(finalizeRelease, "Setup trusted release tooling")).toMatchObject({
uses: "./.github/actions/setup-node-env",
with: { "install-bun": "false" },
});
expect(publishReleaseRun).toContain('git fetch --no-tags --depth=1 origin "${TARGET_SHA}"');
expect(publishReleaseRun).toContain('git show "${TARGET_SHA}:CHANGELOG.md"');
expect(publishReleaseRun).toContain(
"node --import tsx scripts/render-github-release-notes.mts",
);
expect(publishReleaseRun).not.toContain("render-github-release-notes.mjs");
expect(publishReleaseRun.indexOf("render-github-release-notes.mts")).toBeLessThan(
publishReleaseRun.indexOf('gh release create "${RELEASE_TAG}"'),
);
expect(publishReleaseRun).toContain("verify_release_tag_target");
expect(publishReleaseRun).toContain('gh release create "${RELEASE_TAG}"');
expect(publishReleaseRun).toContain("--verify-tag");
@@ -1181,6 +1174,8 @@ describe("release validation no-push transport", () => {
expect(publishReleaseRun).not.toContain("/statuses");
const releasePublishText = readFileSync(releasePublishPath, "utf8");
expect(releasePublishText).not.toContain("prepare_extended_stable_release");
expect(releasePublishText).not.toContain("extended-stable-release-notes-${{ inputs.tag }}");
expect(releasePublishText).not.toContain("verify_extended_stable_docker_completion");
expect(releasePublishText).not.toContain("Docker completion status");
expect(JSON.stringify(dockerRelease)).not.toContain("statuses");
@@ -1199,7 +1194,7 @@ describe("release validation no-push transport", () => {
const releasePublish = readWorkflow(".github/workflows/openclaw-release-publish.yml");
const publishDraft = step(
job(releasePublish, "finalize_extended_stable_github_release"),
"Publish canonical extended-stable release",
"Render and publish canonical extended-stable release",
);
const verifyTag = shellFunctionSource(publishDraft.run ?? "", "verify_release_tag_target");
const targetSha = "a".repeat(40);