ci: publish extended-stable GitHub releases

This commit is contained in:
Dallin Romney
2026-08-08 15:58:11 +08:00
parent 94f042ba86
commit 9b402e6653
21 changed files with 544 additions and 77 deletions
@@ -235,9 +235,9 @@ every human `Thanks @...` attribution.
--release-tag v<YYYY.M.PATCH> \
--check-github
```
- add one `--release-tag` for every beta and stable page in the train; a
`### Release verification` tail is permitted, but any other body drift
fails the check
- add one `--release-tag` for every beta, stable, and extended-stable page in
the train; a `### Release verification` tail is permitted, but any other
body drift fails the check
- `scripts/render-github-release-notes.mts` is the canonical release-body
renderer used by candidate validation, publish, and verification. When the
complete `## YYYY.M.PATCH` section fits GitHub's 125,000-character limit and
@@ -269,11 +269,13 @@ every human `Thanks @...` attribution.
## Extended-Stable Variant
Extended-stable has one release commit and no GitHub Release body. After version
prep and approved backports, regenerate `## YYYY.M.P` with the regular manifest
and original-main-PR provenance rules. Land it by PR, then validate the final
branch tip before tagging. Re-audit after a product backport; a tooling-only
repair needs no changelog entry. Never rewrite a published tag or changelog.
Extended-stable has one release commit and one canonical GitHub Release body.
After version prep and approved backports, regenerate `## YYYY.M.P` with the
regular manifest and original-main-PR provenance rules. Land it by PR, then
validate the final branch tip before tagging. The Docker closeout renders that
tag-owned section into a non-Latest release page. Re-audit after a product
backport; a tooling-only repair needs no changelog entry. Never rewrite a
published tag or changelog.
## Quota / API Outage Rule
+5 -5
View File
@@ -317,11 +317,11 @@ node scripts/full-release-validation-at-sha.mjs \
--workflow-sha "$TOOLING_SHA"
```
That helper is for regular releases. Extended-stable dispatches Full Release
Validation directly from and against `extended-stable/YYYY.M.33` with
`release_profile=stable`; its exact branch-tip evidence is fresh and cannot be
replaced by a `release-ci/*` run. Use `$release-openclaw-ci` for its failure
classification and run-identity rules.
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-openclaw-ci` for failure classification and identity rules.
The helper verifies and pins the recorded Tooling SHA on trusted `main`, passes
the resolved Code SHA as `expected_sha`, and records the canonical release
@@ -13,7 +13,8 @@ user.
Before drafting focus areas, read real release evidence:
1. GitHub release body, or the immutable tag and publish run for extended-stable.
1. GitHub release body and immutable tag; for extended-stable, also confirm the
npm/container-only scope and non-Latest classification.
2. `CHANGELOG.md` section for the released base version.
3. Commits since the previous shipped version or the operator-specified base.
4. Registry/package metadata for the exact version and current dist-tag.
@@ -79,8 +80,8 @@ openclaw --version
Do not add `--yes`: users moving from newer regular stable must see the downgrade
warning because older versions may not understand newer configuration. Link the
tag or changelog; do not imply a GitHub Release or inherit regular stable
macOS, Windows, ClawHub, `latest`, or website claims.
GitHub Release, but do not inherit regular stable macOS, Windows, ClawHub,
`latest`, or website claims.
## Style
@@ -1,4 +1,4 @@
interface:
display_name: "OpenClaw Release Announcement"
short_description: "Draft Discord beta/stable release announcements from evidence."
default_prompt: "Use this skill to draft an OpenClaw beta or stable Discord announcement from changelog, release notes, npm/GitHub release proof, and validation evidence."
short_description: "Draft Discord beta/stable/extended-stable release announcements from evidence."
default_prompt: "Use this skill to draft an OpenClaw beta, stable, or extended-stable Discord announcement from changelog, release notes, npm/GitHub release proof, and validation evidence."
+9 -7
View File
@@ -214,9 +214,9 @@ publish workflow reads the effective profile from the full-validation manifest.
### Extended-stable validation
For `.33+`, dispatch from and target the canonical branch. This direct route is
intentional: downstream extended-stable evidence requires the canonical branch
identity, while Telegram still authenticates the exact branch SHA:
For `.33+`, prefer the trusted main-pinned helper so current workflow tooling
targets the immutable canonical branch tip. A direct canonical-branch run is
also valid:
```bash
RELEASE_SHA="$(git rev-parse HEAD)"
@@ -227,10 +227,12 @@ gh workflow run full-release-validation.yml \
-f release_profile=stable
```
Accept only a complete `rerun_group=all` run whose branch, head/target SHAs,
manifest `workflowRef`, and package versions identify the same commit. Save its
successful `run_attempt` and require the final tag to resolve there. Reject
`release-ci/*`, current-main, narrow, and earlier-attempt evidence.
Accept only a complete `rerun_group=all` run from the canonical branch, from
current `main` whose workflow SHA remains reachable from `main`, or from the
trusted main-pinned `release-ci/*` harness with a v3 exact-target manifest.
Require the head/target SHA, manifest identity, package versions, saved
`run_attempt`, and final tag to identify the same commit. Reject narrow,
untrusted-main, mismatched-target, and earlier-attempt evidence.
Product failures need an approved backport. Frozen-target tooling failures need
the smallest behavior-preserving repair. Provider, approval, runner, or log
+2 -2
View File
@@ -8,8 +8,8 @@ description: "Run or recover OpenClaw macOS release signing, notarization, appca
Use with `$release-openclaw-maintainer`, `$release-openclaw-ci`, `$one-password`, and `$release-private` if it exists when stable macOS assets, release-ops mac preflight, notarization, appcast promotion, or mac release recovery is involved.
This is a regular stable-release skill. Do not invoke it for extended-stable;
that track does not inherit macOS assets, appcast promotion, or a GitHub Release
unless the current extended-stable release policy explicitly adds them.
that track's notes-only GitHub Release does not inherit macOS assets or appcast
promotion.
## Credentials
@@ -303,8 +303,12 @@ on pinned current `main` as the exact command and validation contract.
digest and refuse automatic rollback. For alias repair, dispatch the
approval-gated `docker-channel-promote.yml` from current `main` with the exact
tag; never rebuild or move the release tag.
10. Do not create a GitHub Release or publish macOS, Windows, mobile, website,
ClawHub, or private dist-tag artifacts from this path.
10. Dispatch the Docker-only `OpenClaw Release Publish` closeout. It must
reverify the exact npm selector and tarball, prepare a canonical
non-prerelease GitHub Release draft with `latest=false`, publish Docker,
and only then make the release public. Do not publish macOS, Windows,
mobile, website, ClawHub, npm `latest`, or private dist-tag artifacts from
this path.
## Keep release channel naming aligned
@@ -512,8 +516,8 @@ HEAD/worktree-bound manifest under git metadata for cutover review.
body tail. Do not discard a fitting full contribution record to make room
for proof.
- Before publishing or closing a release, run
`$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable
and beta release tag in the train. Do not publish or leave a page live when
`$openclaw-changelog-update`'s `verify-release-notes.mjs` with every stable,
beta, and extended-stable release tag in the train. Do not publish or leave a page live when
it is missing a source-history reference, eligible human credit, or the
complete matching changelog body.
- Treat the selected `--base` as a strict history boundary: it must be an
@@ -24,8 +24,9 @@ extended-stable package and publication constraints.
- Carry the complete current-main Docker release-channel unit in the tagged
tree: workflow, promoter, policy, shared release-version classifier, tests,
and workflow validation. GitHub evaluates tag-push workflows from that tree.
- Exclude ClawHub publication, GitHub Releases, the macOS app, Windows Hub,
mobile apps, website downloads, and private-repository dist-tags.
- Exclude ClawHub publication, GitHub Release assets, the macOS app, Windows
Hub, mobile apps, website downloads, npm `latest`, and private-repository
dist-tags. The Docker closeout creates a notes-only GitHub Release page.
- Review the complete mainline delta using the shared evidence-driven audit.
Do not stop after the first obvious fixes or consider public PRs, titles, or
dependency bumps the complete source set.
@@ -161,7 +162,8 @@ fi
```
Do not use GitHub's latest nonprerelease Release as the source of truth. The
extended-stable lane intentionally creates no GitHub Release. In bootstrap
npm `extended-stable` selector remains authoritative for the active line; its
notes-only GitHub Release is always created with `latest=false`. In bootstrap
mode, record the approving maintainer and approved base commit. Stop before
discovery or mutation if npm, the canonical branch, tags, package versions,
approved base, or protected `main` disagree.
@@ -238,6 +240,8 @@ path alone.
out of scope.
- Treat macOS-app-only, Windows-Hub-only, mobile-only, website-only, and GitHub
Release-only fixes as `skip` for this Gateway extended-stable line.
- Keep GitHub Release automation repairs in trusted current-main release
tooling; they are not product backports for the maintenance branch.
- Treat cross-repository or package-topology uncertainty as `blocked` until the
shipped npm surface and release owner are proven.
@@ -369,8 +373,9 @@ Report:
harness compatibility repair, and superseded validation runs;
- remaining security, release, or maintainer approvals;
- the coordinated PR URL or why no PR was opened;
- exact intended Docker images and aliases, plus explicit confirmation that no
other non-npm publication is planned.
- exact intended Docker images and aliases, plus explicit confirmation that
the notes-only, non-Latest GitHub Release is the only other publication
planned.
Then follow the parent skill's publish and recovery sequence. Keep exact
branch/tag/package/run identity, never republish for selector repair, and move
@@ -33,6 +33,7 @@ operator steering. Do not preserve superseded scope.
- Plugin NPM Release: `<run id / URL or none>`
- publish parent: `<run id / URL or none>`
- Docker release/repair: `<run ids / tag / aliases or none>`
- GitHub Release: `<draft/public / numeric id / non-Latest readback or none>`
- immutable successful children: `<run ids / artifacts or none>`
- registry/provenance readback: `<artifact or command result>`
@@ -121,8 +121,10 @@ Use `source=npm -f package_spec=openclaw@beta` for published beta proof. Keep
`workflow_ref` as trusted current harness code unless the release process says
otherwise.
For extended-stable, branch-owned Full Release Validation is publication
evidence; Package Acceptance is a post-publish selector smoke:
For extended-stable, complete exact-target Full Release Validation from the
canonical branch, trusted reachable current-main tooling, or the main-pinned
`release-ci/*` harness is publication evidence; Package Acceptance is a
post-publish selector smoke:
```bash
gh workflow run package-acceptance.yml \
+10 -6
View File
@@ -14,8 +14,9 @@ publish skill; use `$release-openclaw-maintainer` before changing release state.
- Resolve short suffixes like `.27` to the concrete CalVer version from the
current date/context, then say the resolved version.
- Resolve the track first. Regular beta/stable uses a GitHub Release and the
platform graph; extended-stable uses its canonical branch, npm selector, and
Gateway surfaces. Do not require one track's artifacts from the other.
platform graph; extended-stable uses a notes-only GitHub Release, its
canonical branch, npm selector, and Gateway surfaces. Do not require one
track's artifacts from the other.
- Verify live state. Do not trust local checkout state, release notes, or old
memory as current truth.
- If the checkout is dirty or divergent, use it only for scripts/reference.
@@ -76,13 +77,15 @@ Use these checks only for the regular orchestrated release track.
## Extended-stable checks
Extended-stable has no GitHub Release ledger. Verify live tag, workflow,
registry, provenance, and image state directly.
Extended-stable has a notes-only GitHub Release ledger. Verify it alongside
the live tag, workflow, registry, provenance, and image state.
1. **Identity:** require final `v<VERSION>` at patch `33+`, with no suffix,
contained in `extended-stable/YYYY.M.33`. Only an active candidate must equal
the tip. Root and every publishable official plugin must declare `<VERSION>`.
Require the Git tag and no GitHub Release.
Require the Git tag and a public, non-prerelease GitHub Release whose title
and canonical body match the tag. Require `isLatest=false` and no native or
platform assets.
2. **Workflow chain:** find successful preflight, complete validation, plugin
npm, and core publish runs on the canonical branch and SHA. Validation must
use `rerun_group=all`, `release_profile=stable`, blocking soak/performance,
@@ -103,7 +106,8 @@ Promotion` for the exact tag, without rebuilding.
6. **Recovery:** never republish. Use the generated command only for the root
selector and approved credential-isolated tooling for others, then repeat
complete readback. Do not require ClawHub, native/mobile apps, website,
private dist-tags, regular `latest`, or a GitHub Release.
private dist-tags, or regular `latest`. Require the notes-only GitHub
Release, but do not require regular-release assets or platform evidence.
## Shared live smoke
+2 -1
View File
@@ -62,8 +62,9 @@
/docs/reference/secretref-credential-surface.md @openclaw/openclaw-secops
/docs/reference/secretref-user-supplied-credentials-matrix.json @openclaw/openclaw-secops
# Release workflow and its supporting release-path checks.
# Release workflows and their supporting release-path checks.
/.github/workflows/openclaw-npm-release.yml @openclaw/openclaw-release-managers
/.github/workflows/openclaw-release-publish.yml @openclaw/openclaw-release-managers
/docs/reference/RELEASING.md @openclaw/openclaw-release-managers
/scripts/openclaw-npm-publish.sh @openclaw/openclaw-release-managers
/scripts/openclaw-npm-release-check.ts @openclaw/openclaw-release-managers
@@ -46,8 +46,50 @@ concurrency:
cancel-in-progress: false
jobs:
release_policy:
name: Classify release trigger
runs-on: ubuntu-latest
outputs:
should_run: ${{ steps.release.outputs.should_run }}
steps:
- name: Download trusted release classifier
if: ${{ github.event_name == 'release' }}
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/lib/release-version.mjs?ref=${WORKFLOW_SHA}" \
--jq .content | base64 --decode > "${RUNNER_TEMP}/release-version.mjs"
- name: Classify release track
id: release
env:
CLASSIFIER_FILE: ${{ runner.temp }}/release-version.mjs
EVENT_NAME: ${{ github.event_name }}
RELEASE_TAG: ${{ github.event.release.tag_name || '' }}
run: |
if [[ "${EVENT_NAME}" != "release" ]]; then
echo "should_run=true" >> "$GITHUB_OUTPUT"
exit 0
fi
node --input-type=module <<'NODE'
import { appendFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const { classifyReleaseTrain, parseReleaseVersion } = await import(
pathToFileURL(process.env.CLASSIFIER_FILE).href
);
const parsed = parseReleaseVersion(process.env.RELEASE_TAG.replace(/^v/u, ""));
const track = parsed === null ? "unknown" : classifyReleaseTrain(parsed);
appendFileSync(process.env.GITHUB_OUTPUT, `should_run=${track !== "extended-stable"}\n`);
NODE
resolve-base:
needs: release_policy
if: >-
needs.release_policy.outputs.should_run == 'true' &&
github.repository == 'openclaw/openclaw' &&
(github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
@@ -12,7 +12,36 @@ jobs:
dispatch-translate:
runs-on: ubuntu-latest
steps:
- name: Download trusted release classifier
env:
GH_TOKEN: ${{ github.token }}
WORKFLOW_SHA: ${{ github.workflow_sha }}
run: |
set -euo pipefail
gh api "repos/${GITHUB_REPOSITORY}/contents/scripts/lib/release-version.mjs?ref=${WORKFLOW_SHA}" \
--jq .content | base64 --decode > "${RUNNER_TEMP}/release-version.mjs"
- name: Classify release track
id: release
env:
CLASSIFIER_FILE: ${{ runner.temp }}/release-version.mjs
RELEASE_TAG: ${{ github.event.release.tag_name }}
run: |
node --input-type=module <<'NODE'
import { appendFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const { classifyReleaseTrain, parseReleaseVersion } = await import(
pathToFileURL(process.env.CLASSIFIER_FILE).href
);
const parsed = parseReleaseVersion(process.env.RELEASE_TAG.replace(/^v/u, ""));
const track = parsed === null ? "unknown" : classifyReleaseTrain(parsed);
appendFileSync(process.env.GITHUB_OUTPUT, `track=${track}\n`);
NODE
- name: Trigger translation coordinator in publish repo
if: ${{ steps.release.outputs.track != 'extended-stable' }}
env:
GH_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
RELEASE_TAG: ${{ github.event.release.tag_name }}
+260 -5
View File
@@ -2469,13 +2469,17 @@ jobs:
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: error
verify_core_npm_registry:
name: Verify already-published core npm package
prepare_extended_stable_release:
name: Verify npm and prepare extended-stable release
needs: [resolve_release_target]
if: ${{ inputs.publish_docker_only }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: read
contents: write
outputs:
release_id: ${{ steps.release.outputs.release_id }}
release_body_sha256: ${{ steps.release.outputs.release_body_sha256 }}
steps:
- name: Verify exact npm and selector readback matches preflight bytes
env:
@@ -2501,10 +2505,175 @@ jobs:
exit 1
fi
- name: Checkout release SHA
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ needs.resolve_release_target.outputs.sha }}
fetch-depth: 1
persist-credentials: false
- name: Checkout trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
path: .release-harness
fetch-depth: 1
persist-credentials: false
- name: Create or resume the canonical draft release
id: release
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
run: |
set -euo pipefail
release_version="${RELEASE_TAG#v}"
release_title="openclaw ${release_version}"
notes_file="${RUNNER_TEMP}/extended-stable-release-notes.md"
verification_file="${RUNNER_TEMP}/extended-stable-release-verification.md"
verify_release_tag_target() {
local direct_sha peeled_sha remote_refs remote_sha
remote_refs="$(git ls-remote --tags origin \
"refs/tags/${RELEASE_TAG}" \
"refs/tags/${RELEASE_TAG}^{}")"
direct_sha="$(printf '%s\n' "${remote_refs}" |
awk -v ref="refs/tags/${RELEASE_TAG}" '$2 == ref { print $1 }')"
peeled_sha="$(printf '%s\n' "${remote_refs}" |
awk -v ref="refs/tags/${RELEASE_TAG}^{}" '$2 == ref { print $1 }')"
remote_sha="${peeled_sha:-${direct_sha}}"
if [[ -z "${remote_sha}" || "${remote_sha}" != "${TARGET_SHA}" ]]; then
echo "Release tag ${RELEASE_TAG} must still resolve to ${TARGET_SHA}; found ${remote_sha:-<missing>}." >&2
exit 1
fi
}
resolve_release_id() {
gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--json databaseId --jq '.databaseId' 2>/dev/null || true
}
wait_for_release_id() {
local attempt release_id
for attempt in $(seq 1 12); do
release_id="$(resolve_release_id)"
if [[ "${release_id}" =~ ^[1-9][0-9]*$ ]]; then
printf '%s\n' "${release_id}"
return 0
fi
if [[ "${attempt}" != "12" ]]; then
sleep 5
fi
done
return 1
}
verify_release_resource() {
local release_id="$1"
local expected_draft="${2:-}"
local body_file="${RUNNER_TEMP}/extended-stable-existing-release.md"
local resource_file="${RUNNER_TEMP}/extended-stable-release.json"
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > "${resource_file}"
jq -r '.body // ""' "${resource_file}" > "${body_file}"
RELEASE_BODY_FILE="${body_file}" \
RELEASE_CHANGELOG_FILE="${GITHUB_WORKSPACE}/CHANGELOG.md" \
RELEASE_REPOSITORY="${GITHUB_REPOSITORY}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_TITLE="${release_title}" \
RELEASE_RESOURCE_FILE="${resource_file}" \
EXPECTED_DRAFT="${expected_draft}" \
node --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
import {
releaseNotesVersionForTag,
verifyGithubReleaseNotes,
} from "./.release-harness/scripts/render-github-release-notes.mjs";
const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8"));
const body = readFileSync(process.env.RELEASE_BODY_FILE, "utf8");
const changelog = readFileSync(process.env.RELEASE_CHANGELOG_FILE, "utf8");
const expectedDraft = process.env.EXPECTED_DRAFT;
const notes = verifyGithubReleaseNotes({
body,
changelog,
version: releaseNotesVersionForTag(process.env.RELEASE_TAG),
tag: process.env.RELEASE_TAG,
repository: process.env.RELEASE_REPOSITORY,
});
if (
release.tag_name !== process.env.RELEASE_TAG ||
release.name !== process.env.RELEASE_TITLE ||
release.prerelease !== false ||
(expectedDraft !== "" && release.draft !== (expectedDraft === "true")) ||
!notes.matches
) {
console.error("Existing GitHub release does not match the canonical extended-stable contract.");
process.exitCode = 1;
}
NODE
}
verify_release_tag_target
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.
- Container publication: this draft is made public only after Docker publication succeeds.
- Excluded: ClawHub, macOS, Windows, mobile, website, and private dist-tags.
EOF
node .release-harness/scripts/render-github-release-notes.mjs \
--changelog CHANGELOG.md \
--tag "${RELEASE_TAG}" \
--repository "${GITHUB_REPOSITORY}" \
--verification-file "${verification_file}" \
--output "${notes_file}"
release_id="$(resolve_release_id)"
if [[ -n "${release_id}" ]]; then
verify_release_resource "${release_id}"
release_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" --jq '.draft')"
if [[ "${release_draft}" == "true" ]]; then
gh release edit "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--title "${release_title}" \
--notes-file "${notes_file}" \
--prerelease=false \
--latest=false
verify_release_resource "${release_id}" true
else
echo "Existing canonical public release will be revalidated after Docker publication."
fi
else
if ! gh release create "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--verify-tag \
--draft \
--title "${release_title}" \
--notes-file "${notes_file}" \
--prerelease=false \
--latest=false; then
echo "Draft creation raced with another writer; resolving the existing release."
fi
release_id="$(wait_for_release_id)" || {
echo "GitHub did not expose a numeric release ID after draft creation." >&2
exit 1
}
verify_release_resource "${release_id}"
fi
release_body_sha256="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" |
jq -j '.body // ""' |
sha256sum |
awk '{print $1}'
)"
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
echo "release_body_sha256=${release_body_sha256}" >> "$GITHUB_OUTPUT"
echo "- Extended-stable GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
publish_docker:
name: Publish Docker images
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')) }}
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')) }}
uses: ./.github/workflows/docker-release.yml
with:
tag: ${{ inputs.tag }}
@@ -2556,3 +2725,89 @@ jobs:
echo "Published GitHub release state does not match the requested draft/prerelease classification." >&2
exit 1
fi
finalize_extended_stable_github_release:
name: Finalize 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' }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: write
steps:
- name: Publish the verified extended-stable draft
env:
GH_TOKEN: ${{ github.token }}
EXPECTED_BODY_SHA256: ${{ needs.prepare_extended_stable_release.outputs.release_body_sha256 }}
RELEASE_ID: ${{ needs.prepare_extended_stable_release.outputs.release_id }}
RELEASE_TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ needs.resolve_release_target.outputs.sha }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_ID}" =~ ^[1-9][0-9]*$ ]]; then
echo "Extended-stable finalization requires a numeric GitHub release ID." >&2
exit 1
fi
if [[ ! "${EXPECTED_BODY_SHA256}" =~ ^[0-9a-f]{64}$ ]]; then
echo "Extended-stable finalization requires a canonical release body digest." >&2
exit 1
fi
release_version="${RELEASE_TAG#v}"
release_title="openclaw ${release_version}"
resource_file="${RUNNER_TEMP}/extended-stable-final-release.json"
verify_release_state() {
local expected_draft="$1"
gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" > "${resource_file}"
RELEASE_RESOURCE_FILE="${resource_file}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_TITLE="${release_title}" \
EXPECTED_BODY_SHA256="${EXPECTED_BODY_SHA256}" \
EXPECTED_DRAFT="${expected_draft}" \
node --input-type=module <<'NODE'
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8"));
const bodySha256 = createHash("sha256").update(release.body ?? "").digest("hex");
if (
release.tag_name !== process.env.RELEASE_TAG ||
release.name !== process.env.RELEASE_TITLE ||
release.prerelease !== false ||
release.draft !== (process.env.EXPECTED_DRAFT === "true") ||
bodySha256 !== process.env.EXPECTED_BODY_SHA256
) {
console.error("GitHub release state does not match the extended-stable contract.");
process.exitCode = 1;
}
NODE
}
current_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --jq '.draft')"
if [[ "${current_draft}" == "true" ]]; then
verify_release_state true
gh api --method PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" \
-F draft=false \
-F prerelease=false \
-f make_latest=false >/dev/null
elif [[ "${current_draft}" != "false" ]]; then
echo "GitHub release ${RELEASE_ID} has an invalid draft state." >&2
exit 1
fi
for attempt in $(seq 1 12); do
if verify_release_state false; then
break
fi
if [[ "${attempt}" == "12" ]]; then
echo "GitHub release ${RELEASE_ID} did not reach its public state." >&2
exit 1
fi
sleep 5
done
latest_tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name')"
if [[ "${latest_tag}" == "${RELEASE_TAG}" ]]; then
echo "Extended-stable release ${RELEASE_TAG} must not become GitHub Latest." >&2
exit 1
fi
echo "- Extended-stable GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
+13 -5
View File
@@ -186,11 +186,13 @@ gh workflow run full-release-validation.yml --ref main \
Gateway extended-stable runs npm preflight, Full Release Validation, and plugin
npm release from `extended-stable/YYYY.M.33`; core publish consumes those three
run IDs plus the validation attempt. `release-ci/*` evidence is invalid because
publish binds every run to the canonical branch and release SHA. The tag
publishes Gateway images and only the `extended-stable*` aliases; the path skips
the regular orchestrator and its ClawHub, native-app, GitHub Release, website,
and private dist-tag surfaces. See [Monthly Gateway extended-stable
run IDs plus the validation attempt. Complete evidence may come from the
canonical branch, reachable current-main tooling, or the trusted main-pinned
`release-ci/*` harness when its v3 manifest binds the exact target and attempt.
The post-npm `OpenClaw Release Publish` closeout publishes Gateway images and
only the `extended-stable*` aliases, then publishes the notes-only GitHub
Release with `latest=false`; it skips ClawHub, native-app, website, and private
dist-tag surfaces. See [Monthly Gateway extended-stable
publication](/reference/RELEASING#monthly-gateway-extended-stable-publication)
for commands and recovery.
@@ -378,6 +380,12 @@ Focused plugin-only repairs use `plugin_publish_scope=selected` with a nonempty
package list. Plugin-only `all-publishable` runs require the same immutable npm
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, publishes Docker, and finalizes the notes-only
non-Latest GitHub Release without entering plugin, ClawHub, or native-app
publication.
```bash
gh workflow run openclaw-release-publish.yml \
--ref main \
+17 -11
View File
@@ -17,7 +17,8 @@ OpenClaw exposes four user-facing update channels:
- dev: the moving head of `main`
Extended-stable ships the trailing month's Gateway, official npm plugins, and
Docker images without moving regular `latest` or `main` selectors.
Docker images without moving regular `latest` or `main` selectors. Each release
also has a notes-only GitHub Release page that is never marked Latest.
Tideclaw alpha builds are a separate internal prerelease track (npm dist-tag `alpha`), covered under [NPM workflow inputs](#npm-workflow-inputs) and [Release test boxes](#release-test-boxes).
@@ -92,7 +93,9 @@ gh workflow run full-release-validation.yml \
The SHA form is preflight-only. Run validation on the canonical branch; publish
binds its workflow ref, head/target SHA, run ID, and attempt. Save both IDs and
the successful `run_attempt`; reject `release-ci/*` evidence.
the successful `run_attempt`. Accept a direct canonical-branch run, a direct
current-`main` run whose workflow SHA remains reachable from `main`, or the
trusted main-pinned `release-ci/*` harness; reject narrow reruns.
Classify failures before editing:
@@ -199,9 +202,11 @@ digest; regular aliases remain unchanged and automatic rollback is rejected.
After that core registry readback succeeds, start Docker publication only through
`OpenClaw Release Publish`. Its Docker-only extended-stable path rechecks the
saved npm preflight artifact, exact `Full Release Validation` evidence, exact npm
version and `extended-stable` selector, and published tarball digest before it
calls the reusable `Docker Release` workflow. A tag push never publishes Docker
images by itself:
version and `extended-stable` selector, and published tarball digest. It then
creates or resumes a canonical non-prerelease GitHub Release draft with
`latest=false`, calls the reusable `Docker Release` workflow, and makes the
draft public only after Docker succeeds. A tag push never publishes Docker
images or a release page by itself:
```bash
gh workflow run openclaw-release-publish.yml \
@@ -221,8 +226,9 @@ an explicit rollback, and never rebuilds images.
Slack, Discord, and Codex are the initial documented support surfaces, not a
release allowlist: every npm-publishable official plugin ships. The regular
checklist alone owns beta/`latest`, GitHub Releases, ClawHub, native apps, mobile,
website, and private dist-tags; do not run those steps for this Gateway path.
checklist alone owns beta/`latest`, GitHub Release assets, ClawHub, native apps,
mobile, website, and private dist-tags; do not run those steps for this Gateway
path. Extended-stable owns only its notes-only, non-Latest GitHub Release page.
## Regular release operator checklist
@@ -562,9 +568,9 @@ For package-candidate Telegram proof, enable `telegram_mode=mock-openai` or `tel
For beta, `latest`, plugin, GitHub Release, and platform publication,
`OpenClaw Release Publish` is the normal mutating entrypoint. The monthly
`.33+` Gateway extended-stable path does not use this orchestrator. The
regular workflow orchestrates the trusted-publisher workflows in the order the
release needs:
`.33+` Gateway extended-stable path also uses its Docker-only closeout after npm
publication. The regular path orchestrates the trusted-publisher workflows in
the order the release needs:
1. Check out the release tag and resolve its commit SHA.
2. Verify the tag is reachable from `main` or `release/*` (or a Tideclaw alpha branch for alpha prereleases).
@@ -723,7 +729,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`: beta or extended-stable recovery/closeout path. It requires `publish_openclaw_npm=false`, complete preflight and Full Release Validation evidence, then verifies the exact npm package, selected dist-tag, and tarball digest before invoking Docker publication.
- `publish_docker_only`: extended-stable-only 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, prepares the notes-only GitHub Release draft, publishes Docker, and makes the release public 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
+10 -8
View File
@@ -53,10 +53,11 @@ not declare the current release-isolation contract or the `expected_sha`
dispatch input; it never silently substitutes newer tooling. The workflow never
creates or updates repository refs itself.
## Extended-stable exception
## Extended-stable validation
Extended-stable publish requires a run whose workflow and target are both the
canonical branch:
Extended-stable publish accepts a complete exact-target run from the canonical
branch, a direct current-main run whose workflow SHA remains reachable from
current `main`, or the trusted main-pinned helper:
```bash
RELEASE_SHA="$(git rev-parse HEAD)"
@@ -67,9 +68,10 @@ gh workflow run full-release-validation.yml \
-f release_profile=stable
```
Do not use `pnpm ci:full-release` or `release-ci/*`. Publish binds the run's
branch, head/target SHA, manifest `workflowRef`, ID, and attempt to the canonical
branch and release commit.
The helper's temporary `release-ci/*` ref is valid only when every child uses
the pinned trusted workflow SHA and the v3 manifest binds the canonical branch,
exact release commit, run ID, and attempt. Reject narrow runs, stale attempts,
untrusted workflow SHAs, and mismatched targets.
Backport product failures; make the smallest behavior-preserving repair for
frozen-target tooling; retry provider, approval, or runner failures without a
@@ -331,8 +333,8 @@ Narrow evidence is not publish authorization by itself.
For a regular release, record both Code SHA and Release SHA, the reuse policy
and changed-path set, the green Code SHA parent run, and the lightweight Release
SHA parent run. For extended-stable, record the canonical branch, exact release
SHA, fresh parent run id and attempt, workflow ref, every child run, and any
frozen-target compatibility repair or intentional omission.
SHA, accepted producer identity, parent run id and attempt, workflow ref, every
child run, and any frozen-target compatibility repair or intentional omission.
Useful artifacts:
+19 -1
View File
@@ -41,6 +41,7 @@ const MANTIS_GITHUB_APP_CLIENT_ID = "Iv23liPJCozR0uHm6P7G";
const OPENGREP_PR_DIFF_WORKFLOW = ".github/workflows/opengrep-precise.yml";
const OPENGREP_FULL_WORKFLOW = ".github/workflows/opengrep-precise-full.yml";
const CONTROL_UI_LOCALE_REFRESH_WORKFLOW = ".github/workflows/control-ui-locale-refresh.yml";
const DOCS_TRANSLATE_RELEASE_WORKFLOW = ".github/workflows/docs-translate-trigger-release.yml";
const NATIVE_APP_LOCALE_REFRESH_WORKFLOW = ".github/workflows/native-app-locale-refresh.yml";
const CREATE_GENERATED_PR_TOKENS_ACTION = ".github/actions/create-generated-pr-tokens/action.yml";
const PUBLISH_GENERATED_PR_ACTION = ".github/actions/publish-generated-pr/action.yml";
@@ -1861,7 +1862,9 @@ NODE
it("keeps locale refresh matrices alive and publishes each aggregate through a PR", () => {
const controlUiWorkflow = parse(readFileSync(CONTROL_UI_LOCALE_REFRESH_WORKFLOW, "utf8"));
const docsTranslateWorkflow = parse(readFileSync(DOCS_TRANSLATE_RELEASE_WORKFLOW, "utf8"));
const workflow = parse(readFileSync(NATIVE_APP_LOCALE_REFRESH_WORKFLOW, "utf8"));
const controlUiReleasePolicy = controlUiWorkflow.jobs.release_policy;
const controlUiResolveBase = controlUiWorkflow.jobs["resolve-base"];
const nativeResolveBase = workflow.jobs["resolve-base"];
const controlUiPreflight = controlUiWorkflow.jobs["publisher-preflight"];
@@ -1914,8 +1917,23 @@ NODE
expect(controlUiResolveBase.if).not.toContain("chore(ui): refresh control ui locales");
const controlResolveCondition = controlUiResolveBase.if.replace(/\s+/gu, " ");
expect(controlResolveCondition).toBe(
"github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')",
"needs.release_policy.outputs.should_run == 'true' && github.repository == 'openclaw/openclaw' && (github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main')",
);
expect(controlUiResolveBase.needs).toBe("release_policy");
expect(controlUiReleasePolicy.outputs.should_run).toBe(
"${{ steps.release.outputs.should_run }}",
);
expect(
controlUiReleasePolicy.steps.find(
(step: WorkflowStep) => step.name === "Classify release track",
).run,
).toContain('track !== "extended-stable"');
const docsDispatch = docsTranslateWorkflow.jobs["dispatch-translate"];
expect(
docsDispatch.steps.find(
(step: WorkflowStep) => step.name === "Trigger translation coordinator in publish repo",
).if,
).toBe("${{ steps.release.outputs.track != 'extended-stable' }}");
expect(controlResolveCondition).not.toContain("inputs.token_preflight_only");
expect(controlResolveCondition).not.toContain("github.ref_type");
expect(nativeResolveBase.if).toBe(
@@ -6246,6 +6246,36 @@ describe("package artifact reuse", () => {
expect(finalizeJob.needs).toEqual(["publish", "publish_docker"]);
expect(finalizeJob.if).toContain("needs.publish_docker.result == 'success'");
expect(finalizeRelease.run).toContain('gh release edit "${RELEASE_TAG}"');
const extendedPrepareJob = workflowJob(
RELEASE_PUBLISH_WORKFLOW,
"prepare_extended_stable_release",
);
const extendedPrepare = workflowStep(
extendedPrepareJob,
"Create or resume the canonical draft release",
);
const extendedFinalizeJob = workflowJob(
RELEASE_PUBLISH_WORKFLOW,
"finalize_extended_stable_github_release",
);
const extendedFinalize = workflowStep(
extendedFinalizeJob,
"Publish the verified extended-stable draft",
);
expect(extendedPrepareJob.needs).toEqual(["resolve_release_target"]);
expect(extendedPrepare.run).toContain("verify_release_tag_target");
expect(extendedPrepare.run).toContain("verifyGithubReleaseNotes");
expect(extendedPrepare.run).toContain("--draft");
expect(extendedPrepare.run).toContain("--latest=false");
expect(extendedFinalizeJob.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"publish_docker",
]);
expect(extendedFinalizeJob.if).toContain("needs.publish_docker.result == 'success'");
expect(extendedFinalize.run).toContain("-f make_latest=false");
expect(extendedFinalize.run).toContain("EXPECTED_BODY_SHA256");
});
it("accepts tag-matched frozen release branches in OpenClaw npm preflight", () => {
+57 -2
View File
@@ -1036,10 +1036,10 @@ describe("release validation no-push transport", () => {
expect(dockerCall.needs).toEqual([
"resolve_release_target",
"publish",
"verify_core_npm_registry",
"prepare_extended_stable_release",
]);
expect(dockerCall.if).toContain("needs.publish.result == 'success'");
expect(dockerCall.if).toContain("needs.verify_core_npm_registry.result == 'success'");
expect(dockerCall.if).toContain("needs.prepare_extended_stable_release.result == 'success'");
expect(dockerCall.with).toEqual({
tag: "${{ inputs.tag }}",
release_sha: "${{ needs.resolve_release_target.outputs.sha }}",
@@ -1068,6 +1068,61 @@ describe("release validation no-push transport", () => {
"publish_docker",
]);
const prepareRelease = job(releasePublish, "prepare_extended_stable_release");
const verifyNpm = step(
prepareRelease,
"Verify exact npm and selector readback matches preflight bytes",
);
const createDraft = step(prepareRelease, "Create or resume the canonical draft release");
const finalizeRelease = job(releasePublish, "finalize_extended_stable_github_release");
const publishDraft = step(finalizeRelease, "Publish the verified extended-stable draft");
expect(prepareRelease.needs).toEqual(["resolve_release_target"]);
expect(prepareRelease.if).toBe("${{ inputs.publish_docker_only }}");
expect(prepareRelease.environment).toBe("npm-release");
expect(prepareRelease.permissions).toEqual({ contents: "write" });
expect(prepareRelease.outputs).toEqual({
release_id: "${{ steps.release.outputs.release_id }}",
release_body_sha256: "${{ steps.release.outputs.release_body_sha256 }}",
});
expect(verifyNpm.run).toContain('npm view "openclaw@${version}" version');
expect(verifyNpm.run).toContain("Published npm tarball does not match");
expect(createDraft.run).toContain("verify_release_tag_target");
expect(createDraft.run).toContain(".release-harness/scripts/render-github-release-notes.mjs");
expect(createDraft.run).toContain('gh release create "${RELEASE_TAG}"');
expect(createDraft.run).toContain("--verify-tag");
expect(createDraft.run).toContain("--draft");
expect(createDraft.run).toContain("--prerelease=false");
expect(createDraft.run).toContain("--latest=false");
expect(createDraft.run).toContain("Existing canonical public release will be revalidated");
expect(createDraft.run).toContain("wait_for_release_id");
expect(createDraft.run).toContain("release_body_sha256=");
expect(finalizeRelease.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"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(publishDraft.env).toMatchObject({
EXPECTED_BODY_SHA256:
"${{ needs.prepare_extended_stable_release.outputs.release_body_sha256 }}",
RELEASE_ID: "${{ needs.prepare_extended_stable_release.outputs.release_id }}",
});
expect(publishDraft.run).toContain("repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}");
expect(publishDraft.run).toContain("-F draft=false");
expect(publishDraft.run).toContain("-F prerelease=false");
expect(publishDraft.run).toContain("-f make_latest=false");
expect(publishDraft.run).toContain("EXPECTED_BODY_SHA256");
expect(publishDraft.run).toContain("releases/latest");
expect(publishDraft.run).toContain("must not become GitHub Latest");
const identity = step(
job(dockerRelease, "validate_release_identity"),
"Verify tag, SHA, and package identity agree",