ci: simplify extended-stable release closeout

This commit is contained in:
Dallin Romney
2026-08-12 00:41:57 +07:00
parent 6d49e844aa
commit 2201b4dd57
17 changed files with 268 additions and 822 deletions
@@ -272,7 +272,7 @@ every human `Thanks @...` attribution.
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
validate the final branch tip before tagging. The release 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.
@@ -302,14 +302,14 @@ 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. 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,
persist and verify the exact-SHA Docker completion status, and only then
make the release public. On retry, skip Docker only for that durable status;
release draft/public state is never completion evidence. Do not publish macOS, Windows,
mobile, website, ClawHub, npm `latest`, or private dist-tag artifacts from
this path.
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.
## Keep release channel naming aligned
@@ -26,7 +26,7 @@ extended-stable package and publication constraints.
and workflow validation. GitHub evaluates tag-push workflows from that tree.
- 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.
dist-tags. The release 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.
@@ -33,7 +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>`
- GitHub Release: `<public URL / non-Latest readback or none>`
- immutable successful children: `<run ids / artifacts or none>`
- registry/provenance readback: `<artifact or command result>`
+4 -4
View File
@@ -101,10 +101,10 @@ the live tag, workflow, registry, provenance, and image state.
digest binding to the release SHA. Preserve output and workflow URLs.
5. **Docker:** verify exact default, slim, browser, and architecture images and
attestations in both registries. Only the three `extended-stable*` aliases may
resolve to those digests. Require the canonical `openclaw/docker-release/<VERSION>`
success status on the immutable release SHA and its `OpenClaw Release Publish`
run URL; release visibility is not Docker evidence. Repair aliases through
current-main `Docker Channel Promotion` for the exact tag, without rebuilding.
resolve to those digests. Require the successful `OpenClaw Release Publish`
closeout run whose Docker job precedes GitHub Release publication. Repair
aliases through current-main `Docker Channel 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,
-38
View File
@@ -904,7 +904,6 @@ jobs:
permissions:
contents: read
packages: write
statuses: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -912,14 +911,6 @@ jobs:
ref: ${{ needs.resolve_build_provenance.outputs.source_sha }}
fetch-depth: 1
- 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
- *buildkit_prepull_step
- name: Set up Docker Builder
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
@@ -1126,32 +1117,3 @@ jobs:
--image-tag-suffix "${IMAGE_TAG_SUFFIX}" \
--image "${GHCR_IMAGE}" \
--image "${DOCKERHUB_IMAGE}"
# This status is the durable resume boundary. Release visibility is not
# Docker evidence and must never suppress a missing publication.
- name: Record durable extended-stable Docker completion
if: ${{ needs.resolve_release_policy.outputs.channel == 'extended-stable' }}
env:
GH_TOKEN: ${{ github.token }}
RELEASE_SHA: ${{ inputs.release_sha }}
VERSION: ${{ needs.resolve_release_policy.outputs.version }}
run: |
set -euo pipefail
payload="$(node .release-harness/scripts/docker-channel-promote.mjs \
--status-payload \
--version "${VERSION}" \
--repository "${GITHUB_REPOSITORY}" \
--source-sha "${RELEASE_SHA}" \
--run-id "${GITHUB_RUN_ID}")"
for attempt in 1 2 3; do
if printf '%s' "${payload}" | gh api --method POST \
"repos/${GITHUB_REPOSITORY}/statuses/${RELEASE_SHA}" \
--input - >/dev/null; then
exit 0
fi
if [[ "${attempt}" != "3" ]]; then
sleep $((attempt * 5))
fi
done
echo "Failed to persist durable Docker completion after verified publication." >&2
exit 1
+111 -378
View File
@@ -84,7 +84,7 @@ on:
default: true
type: boolean
publish_docker_only:
description: Publish Docker only after independently verifying an already-published beta or extended-stable npm package
description: Complete an already-published extended-stable release by verifying npm, publishing Docker, then publishing its GitHub Release
required: true
default: false
type: boolean
@@ -314,7 +314,7 @@ jobs:
if [[ "${PUBLISH_DOCKER_ONLY}" == "true" ]]; then
if [[ ! "${RELEASE_TAG}" =~ ^v([0-9]{4})\.([1-9][0-9]*)\.([1-9][0-9]*)$ ]] || \
(( 10#${BASH_REMATCH[3]:-0} < 33 )); then
echo "Extended-stable Docker closeout requires a final .33+ release tag." >&2
echo "Extended-stable closeout requires a final .33+ release tag." >&2
exit 1
fi
expected_validation_branch="extended-stable/${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.33"
@@ -2782,20 +2782,12 @@ jobs:
if-no-files-found: error
prepare_extended_stable_release:
name: Verify npm and prepare extended-stable release
name: Verify npm and prepare extended-stable release notes
needs: [resolve_release_target]
if: ${{ inputs.publish_docker_only }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
actions: read
contents: write
statuses: read
outputs:
docker_already_published: ${{ steps.release.outputs.docker_already_published }}
docker_status_run_id: ${{ steps.release.outputs.docker_status_run_id }}
release_id: ${{ steps.release.outputs.release_id }}
release_body_sha256: ${{ steps.release.outputs.release_body_sha256 }}
contents: read
steps:
- name: Verify exact npm and selector readback matches preflight bytes
env:
@@ -2821,258 +2813,60 @@ 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: Setup Node environment
- name: Setup trusted release tooling
uses: ./.github/actions/setup-node-env
with:
install-deps: "false"
install-bun: "false"
- name: Install trusted release tooling dependencies
- name: Render canonical extended-stable release notes
env:
CI: "true"
run: |
set -euo pipefail
install_args=(
--dir .release-harness
install
--frozen-lockfile
--prefer-offline
--config.engine-strict=false
--config.enable-pre-post-scripts=true
--config.side-effects-cache=true
)
pnpm "${install_args[@]}" || pnpm "${install_args[@]}"
# Node resolves --import tsx from the process cwd. Point that root at
# the trusted harness install instead of the frozen target checkout.
ln -s .release-harness/node_modules node_modules
- 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"
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}"
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 require_exact_body="${3:-false}"
local resource_file="${RUNNER_TEMP}/extended-stable-release.json"
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}" > "${resource_file}"
RELEASE_BODY_FILE="${notes_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}" \
REQUIRE_EXACT_BODY="${require_exact_body}" \
node --import tsx --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
import {
releaseNotesVersionForTag,
verifyGithubReleaseNotes,
} from "./.release-harness/scripts/render-github-release-notes.mts";
const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8"));
const body = release.body ?? "";
const changelog = readFileSync(process.env.RELEASE_CHANGELOG_FILE, "utf8");
const expectedDraft = process.env.EXPECTED_DRAFT;
const expectedBody = readFileSync(process.env.RELEASE_BODY_FILE, "utf8");
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")) ||
!Array.isArray(release.assets) ||
release.assets.length !== 0 ||
(process.env.REQUIRE_EXACT_BODY === "true" && body !== expectedBody) ||
!notes.matches
) {
console.error("Existing GitHub release does not match the canonical extended-stable contract.");
process.exitCode = 1;
}
NODE
}
wait_until_not_latest() {
local attempt latest_tag
for attempt in $(seq 1 12); do
latest_tag="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true
)"
if [[ -n "${latest_tag}" && "${latest_tag}" != "${RELEASE_TAG}" ]]; then
return 0
fi
if [[ "${attempt}" != "12" ]]; then
sleep 5
fi
done
echo "Extended-stable release ${RELEASE_TAG} must not be GitHub Latest." >&2
return 1
}
verify_release_tag_target
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.
- Container publication: this closeout requires durable Docker completion on the immutable release SHA.
- 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 .release-harness/scripts/render-github-release-notes.mts \
--changelog CHANGELOG.md \
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}"
release_is_public=false
release_id="$(resolve_release_id)"
if [[ -n "${release_id}" ]]; then
verify_release_resource "${release_id}"
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_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 true
elif [[ "${release_draft}" == "false" ]]; then
verify_release_resource "${release_id}" false true
wait_until_not_latest
release_is_public=true
echo "Existing canonical public release found; Docker completion will be checked independently."
else
echo "GitHub release ${release_id} has an invalid draft state." >&2
exit 1
fi
release_body_sha256="$(
sha256sum "${notes_file}" | awk '{print $1}'
)"
docker_status_file="${RUNNER_TEMP}/extended-stable-docker-status.json"
gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/statuses?per_page=100" > "${docker_status_file}"
docker_status_run_id="$(node .release-harness/scripts/docker-channel-promote.mjs \
--find-statuses-file "${docker_status_file}" \
--version "${release_version}" \
--repository "${GITHUB_REPOSITORY}" \
--source-sha "${TARGET_SHA}")"
docker_already_published=false
if [[ -n "${docker_status_run_id}" ]]; then
docker_run_file="${RUNNER_TEMP}/extended-stable-docker-run.json"
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" > "${docker_run_file}"
jq -e --arg repository "${GITHUB_REPOSITORY}" \
--arg run_id "${docker_status_run_id}" '
(.id | tostring) == $run_id and
.name == "OpenClaw Release Publish" and
(.path | split("@")[0]) == ".github/workflows/openclaw-release-publish.yml" and
.event == "workflow_dispatch" and
.status == "completed" and
.repository.full_name == $repository
' "${docker_run_file}" >/dev/null || {
echo "Docker completion status does not reference a completed OpenClaw Release Publish run." >&2
exit 1
}
docker_already_published=true
fi
echo "release_id=${release_id}" >> "$GITHUB_OUTPUT"
echo "release_body_sha256=${release_body_sha256}" >> "$GITHUB_OUTPUT"
echo "docker_already_published=${docker_already_published}" >> "$GITHUB_OUTPUT"
echo "docker_status_run_id=${docker_status_run_id}" >> "$GITHUB_OUTPUT"
if [[ "${docker_already_published}" == "true" ]]; then
echo "- Docker completion: durable status from run ${docker_status_run_id}; rebuild skipped" >> "$GITHUB_STEP_SUMMARY"
elif [[ "${release_is_public}" == "true" ]]; then
echo "- Extended-stable GitHub release: public without Docker completion; Docker publication required" >> "$GITHUB_STEP_SUMMARY"
else
echo "- Extended-stable GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
fi
- 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.prepare_extended_stable_release.outputs.docker_already_published != 'true')) }}
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 }}
@@ -3089,7 +2883,6 @@ jobs:
attestations: read
contents: read
packages: write
statuses: write
publish_vcr:
name: Mirror Docker images to Vercel Container Registry
@@ -3132,130 +2925,37 @@ jobs:
exit 1
fi
verify_extended_stable_docker_completion:
name: Verify durable extended-stable Docker completion
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.publish_docker.result == 'skipped' && needs.prepare_extended_stable_release.outputs.docker_already_published == 'true')) }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
statuses: read
steps:
- name: Checkout trusted release tooling
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.sha }}
fetch-depth: 1
persist-credentials: false
- name: Verify durable Docker completion status
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}"
status_file="${RUNNER_TEMP}/extended-stable-docker-status.json"
docker_status_run_id=""
for attempt in $(seq 1 12); do
gh api "repos/${GITHUB_REPOSITORY}/commits/${TARGET_SHA}/statuses?per_page=100" > "${status_file}"
docker_status_run_id="$(node scripts/docker-channel-promote.mjs \
--find-statuses-file "${status_file}" \
--version "${release_version}" \
--repository "${GITHUB_REPOSITORY}" \
--source-sha "${TARGET_SHA}")"
if [[ -n "${docker_status_run_id}" ]]; then
break
fi
if [[ "${attempt}" == "12" ]]; then
echo "Docker publication completed without a durable completion status." >&2
exit 1
fi
sleep 5
done
docker_run_file="${RUNNER_TEMP}/extended-stable-docker-run.json"
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" > "${docker_run_file}"
jq -e --arg current_run_id "${GITHUB_RUN_ID}" \
--arg repository "${GITHUB_REPOSITORY}" \
--arg run_id "${docker_status_run_id}" '
(.id | tostring) == $run_id and
.name == "OpenClaw Release Publish" and
(.path | split("@")[0]) == ".github/workflows/openclaw-release-publish.yml" and
.event == "workflow_dispatch" and
(.status == "completed" or ($run_id == $current_run_id and .status == "in_progress")) and
.repository.full_name == $repository
' "${docker_run_file}" >/dev/null || {
echo "Docker completion status does not reference the expected release workflow run." >&2
exit 1
}
echo "- Durable Docker completion: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${docker_status_run_id}" >> "$GITHUB_STEP_SUMMARY"
finalize_extended_stable_github_release:
name: Finalize extended-stable GitHub release
needs:
[
resolve_release_target,
prepare_extended_stable_release,
verify_extended_stable_docker_completion,
]
if: ${{ always() && inputs.publish_docker_only && needs.prepare_extended_stable_release.result == 'success' && needs.verify_extended_stable_docker_completion.result == 'success' }}
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' }}
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: write
steps:
- name: Publish the verified extended-stable draft
- name: Download canonical extended-stable release notes
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v8.0.1
with:
name: extended-stable-release-notes-${{ inputs.tag }}
path: ${{ runner.temp }}/extended-stable-release-notes
- name: Publish canonical extended-stable release
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"
notes_file="${RUNNER_TEMP}/extended-stable-release-notes/release-notes.md"
resource_file="${RUNNER_TEMP}/extended-stable-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") ||
!Array.isArray(release.assets) ||
release.assets.length !== 0 ||
bodySha256 !== process.env.EXPECTED_BODY_SHA256
) {
console.error("GitHub release state does not match the extended-stable contract.");
process.exitCode = 1;
}
NODE
}
if [[ ! -s "${notes_file}" ]]; then
echo "Canonical extended-stable release notes artifact is missing or empty." >&2
exit 1
fi
verify_release_tag_target() {
local direct_sha peeled_sha remote_refs remote_sha
@@ -3268,49 +2968,82 @@ jobs:
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
echo "Release tag ${RELEASE_TAG} must resolve to ${TARGET_SHA}; found ${remote_sha:-<missing>}." >&2
exit 1
fi
}
# Docker publication can take long enough for the tag to change after
# preparation. Recheck immediately before accepting or publishing the page.
verify_release_tag_target
current_draft="$(gh api "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --jq '.draft')"
if [[ "${current_draft}" == "true" ]]; then
verify_release_state true
# GITHUB_TOKEN publication intentionally does not fan out release.published
# workflows; extended-stable scope stays owned by this closeout.
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
fetch_release_resource() {
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${RELEASE_TAG}" \
> "${resource_file}" 2>/dev/null
}
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
for attempt in $(seq 1 12); do
verify_release_resource() {
local expected_draft="$1"
RELEASE_BODY_FILE="${notes_file}" \
RELEASE_RESOURCE_FILE="${resource_file}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_TITLE="${release_title}" \
EXPECTED_DRAFT="${expected_draft}" \
node --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
const release = JSON.parse(readFileSync(process.env.RELEASE_RESOURCE_FILE, "utf8"));
const expectedBody = readFileSync(process.env.RELEASE_BODY_FILE, "utf8");
if (
release.tag_name !== process.env.RELEASE_TAG ||
release.name !== process.env.RELEASE_TITLE ||
release.draft !== (process.env.EXPECTED_DRAFT === "true") ||
release.prerelease !== false ||
!Array.isArray(release.assets) ||
release.assets.length !== 0 ||
(release.body ?? "") !== expectedBody
) {
console.error("GitHub release does not match the canonical extended-stable contract.");
process.exitCode = 1;
}
NODE
}
wait_until_public_and_canonical() {
local attempt
for attempt in $(seq 1 12); do
if fetch_release_resource && verify_release_resource false; then
return 0
fi
[[ "${attempt}" == "12" ]] || sleep 5
done
echo "Extended-stable GitHub release did not reach its canonical public state." >&2
return 1
}
verify_not_latest() {
local latest_tag
latest_tag="$(
gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name' 2>/dev/null || true
)"
if [[ -n "${latest_tag}" && "${latest_tag}" != "${RELEASE_TAG}" ]]; then
break
if [[ -z "${latest_tag}" || "${latest_tag}" == "${RELEASE_TAG}" ]]; then
echo "Extended-stable release ${RELEASE_TAG} must not be GitHub Latest." >&2
return 1
fi
if [[ "${attempt}" == "12" ]]; then
echo "Extended-stable release ${RELEASE_TAG} must not become GitHub Latest." >&2
exit 1
}
verify_release_tag_target
if fetch_release_resource; then
verify_release_resource false
verify_not_latest
echo "- Extended-stable GitHub release: already public and canonical" >> "${GITHUB_STEP_SUMMARY}"
exit 0
else
if ! gh release create "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" \
--verify-tag \
--title "${release_title}" \
--notes-file "${notes_file}" \
--latest=false; then
echo "Release creation raced with another writer; verifying the existing page."
fi
sleep 5
done
echo "- Extended-stable GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
fi
wait_until_public_and_canonical
verify_not_latest
echo "- Extended-stable GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "${GITHUB_STEP_SUMMARY}"
@@ -73,8 +73,7 @@ jobs:
}
BASH
- name: Checkout pushed main
if: ${{ github.event_name == 'push' }}
- name: Checkout trusted release tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.sha }}
@@ -98,9 +97,25 @@ jobs:
. "$RUNNER_TEMP/github-api-backoff.sh"
if [[ "$EVENT_NAME" == "push" ]]; then
main_ref="$TRIGGER_SHA"
tag="$(gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
--json tagName,isPrerelease,publishedAt \
--jq '[.[] | select(.isPrerelease | not) | select(.tagName | test("^v[0-9]{4}\\.[0-9]+\\.[0-9]+(-[0-9]+)?$"))] | sort_by(.publishedAt) | last | .tagName // empty')"
releases_file="$RUNNER_TEMP/published-releases.json"
gh_with_retry release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --limit 100 \
--json tagName,isPrerelease,publishedAt > "$releases_file"
tag="$(RELEASES_FILE="$releases_file" node --input-type=module <<'NODE'
import { readFileSync } from "node:fs";
import { classifyReleaseTrain, parseReleaseVersion } from "./scripts/lib/release-version.mjs";
const releases = JSON.parse(readFileSync(process.env.RELEASES_FILE, "utf8"));
const candidates = releases
.filter((release) => release?.isPrerelease === false)
.filter((release) => {
const tag = typeof release?.tagName === "string" ? release.tagName : "";
const parsed = tag.startsWith("v") ? parseReleaseVersion(tag.slice(1)) : null;
return parsed !== null && classifyReleaseTrain(parsed) === "stable";
})
.toSorted((left, right) => String(left.publishedAt).localeCompare(String(right.publishedAt)));
process.stdout.write(candidates.at(-1)?.tagName ?? "");
NODE
)"
if [[ -z "$tag" ]]; then
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
@@ -108,12 +123,21 @@ jobs:
else
tag="$MANUAL_TAG"
fi
if [[ ! "$tag" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-[0-9]+)?$ ]]; then
if ! RELEASE_TAG="$tag" node --input-type=module <<'NODE'
import { classifyReleaseTrain, parseReleaseVersion } from "./scripts/lib/release-version.mjs";
const tag = process.env.RELEASE_TAG ?? "";
const parsed = tag.startsWith("v") ? parseReleaseVersion(tag.slice(1)) : null;
if (parsed === null || classifyReleaseTrain(parsed) !== "stable") {
process.exitCode = 1;
}
NODE
then
if [[ "$EVENT_NAME" == "push" ]]; then
echo "should_closeout=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Stable main closeout accepts only a stable vYYYY.M.PATCH or vYYYY.M.PATCH-N tag, got $tag." >&2
echo "Stable main closeout accepts only a regular stable vYYYY.M.PATCH or vYYYY.M.PATCH-N tag below the extended-stable .33 boundary, got $tag." >&2
exit 1
fi
release_asset_version="${tag#v}"
+5 -5
View File
@@ -393,11 +393,11 @@ 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,
publishes Docker, records durable Docker completion on the immutable release
SHA, and finalizes the notes-only non-Latest GitHub Release without entering
plugin, ClawHub, or native-app publication. A public release page never skips
Docker by itself; only the verified completion status can make a retry resume
after Docker.
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
repeating a successful Docker job; a fresh dispatch repeats the idempotent
same-version Docker verification and promotion.
```bash
gh workflow run openclaw-release-publish.yml \
+10 -11
View File
@@ -200,17 +200,16 @@ must advance only
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
`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
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. After image, attestation, platform,
and channel-alias verification, Docker Release records a success status on the
immutable release SHA. Retries skip a rebuild only when that exact durable
status and its release-workflow run are verified; a public GitHub Release page
alone is never Docker-completion evidence. A tag push never publishes Docker
images or a release page by itself:
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
promotion. A tag push never publishes Docker images or a release page by
itself:
```bash
gh workflow run openclaw-release-publish.yml \
@@ -591,7 +590,7 @@ 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 also uses its Docker-only closeout after npm
`.33+` Gateway extended-stable path also uses its closeout after npm
publication. The regular path orchestrates the trusted-publisher workflows in
the order the release needs:
@@ -752,7 +751,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`: 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`.
- `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`.
- `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
+1 -1
View File
@@ -110,7 +110,7 @@ gh workflow run full-release-validation.yml \
-f release_profile=stable
```
The Docker-only closeout derives `extended-stable/YYYY.M.33` from the final
The extended-stable closeout derives `extended-stable/YYYY.M.33` from the final
`.33+` tag, requires the tag commit to be reachable from that exact branch, and
passes that branch to the validation-evidence verifier. It does not substitute
the closeout workflow's `main` ref for the candidate branch.
-23
View File
@@ -394,7 +394,6 @@ function main() {
args: process.argv.slice(2),
options: {
"allow-rollback": { type: "boolean" },
"find-statuses-file": { type: "string" },
help: { type: "boolean", short: "h" },
image: { type: "string", multiple: true },
"image-tag-suffix": { type: "string", default: "" },
@@ -414,28 +413,6 @@ function main() {
if (!version) {
throw new Error("--version is required.");
}
if (values["status-payload"]) {
const payload = createDockerPublicationStatus({
version,
repository: values.repository ?? "",
sourceSha: values["source-sha"] ?? "",
runId: values["run-id"] ?? "",
});
process.stdout.write(`${JSON.stringify(payload)}\n`);
return;
}
if (values["find-statuses-file"]) {
const match = findDockerPublicationStatus({
statuses: JSON.parse(readFileSync(values["find-statuses-file"], "utf8")),
version,
repository: values.repository ?? "",
sourceSha: values["source-sha"] ?? "",
});
if (match) {
process.stdout.write(`${match.runId}\n`);
}
return;
}
const images = (values.image ?? []).map((image) => image.trim());
if (images.length === 0 || images.some((image) => image.length === 0)) {
throw new Error("At least one non-empty --image is required.");
@@ -3,7 +3,11 @@
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { normalizeUpgradeSurvivorBaselineSpec } from "./lib/docker-e2e-plan.mts";
import { compareReleaseVersions, parseReleaseVersion } from "./lib/release-version.mjs";
import {
classifyReleaseTrain,
compareReleaseVersions,
parseReleaseVersion,
} from "./lib/release-version.mjs";
type ReleaseRecord = Partial<Record<"isPrerelease" | "publishedAt" | "tagName", unknown>>;
@@ -73,7 +77,7 @@ function stableVersionFromTag(tagName: unknown) {
function parseStableVersion(version: unknown) {
const parsed = parseReleaseVersion(typeof version === "string" ? version : "");
return parsed?.channel === "stable" ? parsed : undefined;
return parsed && classifyReleaseTrain(parsed) === "stable" ? parsed : undefined;
}
function compareStableVersions(left: string, right: string) {
-138
View File
@@ -6,8 +6,6 @@ import { describe, expect, it, vi } from "vitest";
import { parse } from "yaml";
import {
createDockerChannelPromotionPlan,
createDockerPublicationStatus,
findDockerPublicationStatus,
promoteDockerChannel,
} from "../../scripts/docker-channel-promote.mjs";
@@ -106,132 +104,6 @@ const bashRunsWorkflowSteps =
spawnSync("bash", ["-c", "type mapfile"], { encoding: "utf8" }).status === 0;
describe("Docker channel promotion", () => {
it("accepts the newest canonical status from reverse-chronological history", () => {
const sourceSha = "a".repeat(40);
const payload = createDockerPublicationStatus({
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
runId: "12345",
});
expect(payload).toEqual({
context: "openclaw/docker-release/2026.6.35",
description:
"Verified GHCR + Docker Hub images, attestations, platforms, and channel aliases.",
state: "success",
target_url: "https://github.com/openclaw/openclaw/actions/runs/12345",
});
expect(
findDockerPublicationStatus({
statuses: [
{ context: "unrelated/status", state: "success" },
{
...payload,
creator: { login: "github-actions[bot]" },
url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`,
},
{
...payload,
creator: { login: "github-actions[bot]" },
target_url: "https://github.com/openclaw/openclaw/actions/runs/11111",
url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`,
},
],
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toEqual({ runId: "12345", targetUrl: payload.target_url });
});
it("does not accept absent, malformed, or combined-response status evidence", () => {
const sourceSha = "a".repeat(40);
expect(
findDockerPublicationStatus({
statuses: [],
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toBeNull();
const payload = createDockerPublicationStatus({
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
runId: "12345",
});
const canonical = {
...payload,
creator: { login: "github-actions[bot]" },
url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`,
};
for (const status of [
{ ...canonical, state: "pending" },
{ ...canonical, creator: { login: "someone-else" } },
{
...canonical,
description: "images probably published",
},
{
...canonical,
url: `https://api.github.com/repos/openclaw/openclaw/statuses/${"b".repeat(40)}`,
},
]) {
expect(() =>
findDockerPublicationStatus({
statuses: [status],
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toThrow("is not canonical");
}
expect(() =>
findDockerPublicationStatus({
statuses: { sha: sourceSha, statuses: [] },
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toThrow("status history must be an array");
});
it("fails closed on a malformed newest matching status", () => {
const sourceSha = "a".repeat(40);
const payload = createDockerPublicationStatus({
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
runId: "12345",
});
const canonical = {
...payload,
creator: { login: "github-actions[bot]" },
url: `https://api.github.com/repos/openclaw/openclaw/statuses/${sourceSha}`,
};
expect(() =>
findDockerPublicationStatus({
statuses: [{ ...canonical, description: "unverified images" }, canonical],
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toThrow("is not canonical");
expect(() =>
findDockerPublicationStatus({
statuses: [{ ...canonical, context: canonical.context.toUpperCase() }, canonical],
version: "2026.6.35",
repository: "openclaw/openclaw",
sourceSha,
}),
).toThrow("is not canonical");
});
it("plans every extended-stable image variant in both registries", () => {
expect(createDockerChannelPromotionPlan({ version: "2026.6.33", images })).toEqual({
channel: "extended-stable",
@@ -800,7 +672,6 @@ describe("Docker channel promotion", () => {
expect(verifyAttestations.permissions).toEqual({
contents: "read",
packages: "write",
statuses: "write",
});
const manifestTagStep = createManifest.steps?.find(
@@ -828,15 +699,6 @@ describe("Docker channel promotion", () => {
"node scripts/docker-channel-promote.mjs",
);
expect(releaseSteps[releasePromotionIndex]?.run).not.toContain("--allow-rollback");
const completionIndex = releaseSteps.findIndex(
(step) => step.name === "Record durable extended-stable Docker completion",
);
expect(completionIndex).toBeGreaterThan(releasePromotionIndex);
expect(releaseSteps[completionIndex]?.if).toBe(
"${{ needs.resolve_release_policy.outputs.channel == 'extended-stable' }}",
);
expect(releaseSteps[completionIndex]?.run).toContain("--status-payload");
expect(releaseSteps[completionIndex]?.run).toContain("statuses/${RELEASE_SHA}");
expect(
Object.values(releaseWorkflow.jobs ?? {}).flatMap((job) =>
(job.steps ?? []).filter(
@@ -2096,6 +2096,9 @@ describe("package acceptance workflow", () => {
expect(workflow).toContain("main_ref: ${{ steps.inputs.outputs.main_ref }}");
expect(workflow).toContain("TRIGGER_SHA: ${{ github.sha }}");
expect(workflow).toContain('main_ref="$TRIGGER_SHA"');
expect(workflow).toContain('classifyReleaseTrain(parsed) === "stable"');
expect(workflow).toContain('classifyReleaseTrain(parsed) !== "stable"');
expect(workflow).toContain("below the extended-stable .33 boundary");
expect(workflow).toContain("ref: ${{ needs.resolve.outputs.main_ref }}");
expect(workflow).toContain(
"Stable closeout skipped: $evidence_source_tag predates immutable postpublish evidence.",
@@ -6575,98 +6578,6 @@ 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 extendedPrepareSteps = extendedPrepareJob.steps ?? [];
const extendedPrepareStepNames = extendedPrepareSteps.map((step) => step.name);
const extendedHarnessCheckoutIndex = extendedPrepareStepNames.indexOf(
"Checkout trusted release tooling",
);
const extendedSetupIndex = extendedPrepareStepNames.indexOf("Setup Node environment");
const extendedInstallIndex = extendedPrepareStepNames.indexOf(
"Install trusted release tooling dependencies",
);
const extendedDraftIndex = extendedPrepareStepNames.indexOf(
"Create or resume the canonical draft release",
);
const extendedDockerCompletionJob = workflowJob(
RELEASE_PUBLISH_WORKFLOW,
"verify_extended_stable_docker_completion",
);
const extendedDockerCompletion = workflowStep(
extendedDockerCompletionJob,
"Verify durable Docker completion status",
);
const extendedFinalizeJob = workflowJob(
RELEASE_PUBLISH_WORKFLOW,
"finalize_extended_stable_github_release",
);
const extendedFinalize = workflowStep(
extendedFinalizeJob,
"Publish the verified extended-stable draft",
);
const extendedFinalizeRun = extendedFinalize.run ?? "";
expect(extendedPrepareJob.needs).toEqual(["resolve_release_target"]);
expect(extendedSetupIndex).toBeGreaterThan(extendedHarnessCheckoutIndex);
expect(extendedInstallIndex).toBeGreaterThan(extendedSetupIndex);
expect(extendedDraftIndex).toBeGreaterThan(extendedInstallIndex);
expect(workflowStep(extendedPrepareJob, "Setup Node environment").with).toMatchObject({
"install-bun": "false",
"install-deps": "false",
});
expect(
workflowStep(extendedPrepareJob, "Install trusted release tooling dependencies").run,
).toContain("ln -s .release-harness/node_modules node_modules");
expect(extendedPrepare.run).toContain("verify_release_tag_target");
expect(extendedPrepare.run).toContain("verifyGithubReleaseNotes");
expect(extendedPrepare.run).toContain(
"node --import tsx .release-harness/scripts/render-github-release-notes.mts",
);
expect(extendedPrepare.run).toContain("node --import tsx --input-type=module");
expect(extendedPrepare.run).not.toContain("render-github-release-notes.mjs");
expect(extendedPrepare.run).toContain("body !== expectedBody");
expect(extendedPrepare.run).toContain("release.assets.length !== 0");
expect(extendedPrepare.run).toContain("commits/${TARGET_SHA}/statuses?per_page=100");
expect(extendedPrepare.run).toContain("--find-statuses-file");
expect(extendedPrepare.run).not.toContain("commits/${TARGET_SHA}/status?per_page=100");
expect(extendedPrepare.run).toContain("docker_already_published=true");
expect(extendedPrepare.run).toContain("public without Docker completion");
expect(extendedPrepare.run).toContain("--draft");
expect(extendedPrepare.run).toContain("--latest=false");
expect(extendedDockerCompletionJob.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"publish_docker",
]);
expect(extendedDockerCompletion.run).toContain("commits/${TARGET_SHA}/statuses?per_page=100");
expect(extendedDockerCompletion.run).toContain("--find-statuses-file");
expect(extendedDockerCompletion.run).not.toContain("commits/${TARGET_SHA}/status?per_page=100");
expect(extendedDockerCompletion.run).toContain("Docker publication completed without");
expect(extendedFinalizeJob.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"verify_extended_stable_docker_completion",
]);
expect(extendedFinalizeJob.if).toContain(
"needs.verify_extended_stable_docker_completion.result == 'success'",
);
expect(extendedFinalizeRun).toContain("-f make_latest=false");
expect(extendedFinalizeRun).toContain("EXPECTED_BODY_SHA256");
expect(extendedFinalizeRun).toContain("release.assets.length !== 0");
expect(extendedFinalizeRun).toContain("for attempt in $(seq 1 12)");
expect(extendedFinalizeRun).toContain(
'git ls-remote --tags "https://github.com/${GITHUB_REPOSITORY}.git"',
);
expect(extendedFinalizeRun.indexOf("verify_release_tag_target\n")).toBeLessThan(
extendedFinalizeRun.indexOf('current_draft="$(gh api'),
);
});
it("accepts tag-matched frozen release branches in OpenClaw npm preflight", () => {
+60 -112
View File
@@ -1516,9 +1516,6 @@ describe("release validation no-push transport", () => {
]);
expect(dockerCall.if).toContain("needs.publish.result == 'success'");
expect(dockerCall.if).toContain("needs.prepare_extended_stable_release.result == 'success'");
expect(dockerCall.if).toContain(
"needs.prepare_extended_stable_release.outputs.docker_already_published != 'true'",
);
expect(dockerCall.with).toEqual({
tag: "${{ inputs.tag }}",
release_sha: "${{ needs.resolve_release_target.outputs.sha }}",
@@ -1556,134 +1553,85 @@ describe("release validation no-push transport", () => {
]);
const prepareRelease = job(releasePublish, "prepare_extended_stable_release");
const prepareSteps = prepareRelease.steps ?? [];
const prepareStepNames = prepareSteps.map((workflowStep) => workflowStep.name);
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 prepareSteps = prepareRelease.steps ?? [];
const prepareStepNames = prepareSteps.map((workflowStep) => workflowStep.name);
const checkoutHarnessIndex = prepareStepNames.indexOf("Checkout trusted release tooling");
const setupNodeIndex = prepareStepNames.indexOf("Setup Node environment");
const installHarnessIndex = prepareStepNames.indexOf(
"Install trusted release tooling dependencies",
);
const createDraftIndex = prepareStepNames.indexOf(
"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");
const publishDraftRun = publishDraft.run ?? "";
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).toBe("npm-release");
expect(prepareRelease.permissions).toEqual({
actions: "read",
contents: "write",
statuses: "read",
});
expect(prepareRelease.outputs).toEqual({
docker_already_published: "${{ steps.release.outputs.docker_already_published }}",
docker_status_run_id: "${{ steps.release.outputs.docker_status_run_id }}",
release_id: "${{ steps.release.outputs.release_id }}",
release_body_sha256: "${{ steps.release.outputs.release_body_sha256 }}",
});
expect(setupNodeIndex).toBeGreaterThan(checkoutHarnessIndex);
expect(installHarnessIndex).toBeGreaterThan(setupNodeIndex);
expect(createDraftIndex).toBeGreaterThan(installHarnessIndex);
expect(step(prepareRelease, "Setup Node environment")).toMatchObject({
uses: "./.github/actions/setup-node-env",
with: {
"install-bun": "false",
"install-deps": "false",
},
});
expect(step(prepareRelease, "Install trusted release tooling dependencies").run).toContain(
"--dir .release-harness",
);
expect(step(prepareRelease, "Install trusted release tooling dependencies").run).toContain(
"ln -s .release-harness/node_modules node_modules",
);
expect(prepareRelease.environment).toBeUndefined();
expect(prepareRelease.permissions).toEqual({ contents: "read" });
expect(setupIndex).toBeGreaterThan(checkoutIndex);
expect(renderIndex).toBeGreaterThan(setupIndex);
expect(uploadIndex).toBeGreaterThan(renderIndex);
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(
"node --import tsx .release-harness/scripts/render-github-release-notes.mts",
);
expect(createDraft.run).toContain("node --import tsx --input-type=module");
expect(createDraft.run).not.toContain("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("body !== expectedBody");
expect(createDraft.run).toContain("release.assets.length !== 0");
expect(createDraft.run).toContain('verify_release_resource "${release_id}" false true');
expect(createDraft.run).toContain("wait_until_not_latest");
expect(createDraft.run).toContain("Docker completion will be checked independently");
expect(createDraft.run).toContain("commits/${TARGET_SHA}/statuses?per_page=100");
expect(createDraft.run).toContain("--find-statuses-file");
expect(createDraft.run).not.toContain("commits/${TARGET_SHA}/status?per_page=100");
expect(createDraft.run).toContain("docker_already_published=true");
expect(createDraft.run).toContain("public without Docker completion");
expect(createDraft.run).toContain("wait_for_release_id");
expect(createDraft.run).toContain('sha256sum "${notes_file}"');
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 publishReleaseRun = publishRelease.run ?? "";
const verifyDockerCompletion = job(releasePublish, "verify_extended_stable_docker_completion");
expect(verifyDockerCompletion.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"publish_docker",
]);
expect(verifyDockerCompletion.if).toContain(
"needs.prepare_extended_stable_release.outputs.docker_already_published == 'true'",
);
const verifyDockerCompletionRun = step(
verifyDockerCompletion,
"Verify durable Docker completion status",
).run;
expect(verifyDockerCompletionRun).toContain("commits/${TARGET_SHA}/statuses?per_page=100");
expect(verifyDockerCompletionRun).toContain("--find-statuses-file");
expect(verifyDockerCompletionRun).not.toContain("commits/${TARGET_SHA}/status?per_page=100");
expect(finalizeRelease.needs).toEqual([
"resolve_release_target",
"prepare_extended_stable_release",
"verify_extended_stable_docker_completion",
"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.verify_extended_stable_docker_completion.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(downloadNotes.with).toMatchObject({
name: "extended-stable-release-notes-${{ inputs.tag }}",
});
expect(publishDraftRun).toContain("repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}");
expect(publishDraftRun).toContain("-F draft=false");
expect(publishDraftRun).toContain("-F prerelease=false");
expect(publishDraftRun).toContain("-f make_latest=false");
expect(publishDraftRun).toContain("EXPECTED_BODY_SHA256");
expect(publishDraftRun).toContain("release.assets.length !== 0");
expect(publishDraftRun).toContain("releases/latest");
expect(publishDraftRun).toContain("must not become GitHub Latest");
expect(publishDraftRun).toContain("for attempt in $(seq 1 12)");
expect(publishDraftRun).toContain(
"GITHUB_TOKEN publication intentionally does not fan out release.published",
);
expect(publishDraftRun).toContain(
'git ls-remote --tags "https://github.com/${GITHUB_REPOSITORY}.git"',
);
expect(publishDraftRun.indexOf("verify_release_tag_target\n")).toBeLessThan(
publishDraftRun.indexOf('current_draft="$(gh api'),
);
expect(publishReleaseRun).toContain("verify_release_tag_target");
expect(publishReleaseRun).toContain('gh release create "${RELEASE_TAG}"');
expect(publishReleaseRun).toContain("--verify-tag");
expect(publishReleaseRun).not.toContain("--draft");
expect(publishReleaseRun).not.toContain("--prerelease");
expect(publishReleaseRun).toContain("--latest=false");
expect(publishReleaseRun).toContain("release.assets.length !== 0");
expect(publishReleaseRun).toContain('(release.body ?? "") !== expectedBody');
expect(publishReleaseRun).toContain("verify_release_resource false");
expect(publishReleaseRun).not.toContain("--draft=false");
expect(publishReleaseRun).toContain("already public and canonical");
expect(publishReleaseRun).toContain("releases/latest");
expect(publishReleaseRun).toContain("must not be GitHub Latest");
expect(publishReleaseRun).not.toContain("docker_already_published");
expect(publishReleaseRun).not.toContain("/statuses");
const releasePublishText = readFileSync(releasePublishPath, "utf8");
expect(releasePublishText).not.toContain("verify_extended_stable_docker_completion");
expect(releasePublishText).not.toContain("Docker completion status");
expect(JSON.stringify(dockerRelease)).not.toContain("statuses");
const identity = step(
job(dockerRelease, "validate_release_identity"),
@@ -1699,7 +1647,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 the verified extended-stable draft",
"Publish canonical extended-stable release",
);
const verifyTag = shellFunctionSource(publishDraft.run ?? "", "verify_release_tag_target");
const targetSha = "a".repeat(40);
@@ -1738,7 +1686,7 @@ verify_release_tag_target
const moved = runVerify(`${"c".repeat(40)}\trefs/tags/v2026.6.35\n`);
expect(moved.status).toBe(1);
expect(moved.stderr).toContain(`must still resolve to ${targetSha}`);
expect(moved.stderr).toContain(`must resolve to ${targetSha}`);
const missing = runVerify("");
expect(missing.status).toBe(1);
@@ -164,6 +164,32 @@ describe("scripts/resolve-upgrade-survivor-baselines", () => {
});
});
it("excludes extended-stable GitHub releases from regular stable baselines", () => {
const releases = [
{
isPrerelease: false,
publishedAt: "2026-08-02T00:00:00Z",
tagName: "v2026.6.34",
},
{
isPrerelease: false,
publishedAt: "2026-08-01T00:00:00Z",
tagName: "v2026.7.12",
},
];
withReleaseFixture(releases, (file) => {
expect(
resolveBaselines(
new Map([
["requested", "last-stable-1"],
["releases-json", file],
]),
),
).toEqual(["openclaw@2026.7.12"]);
});
});
it("rejects loose release-history count values", () => {
withReleaseFixture([], (file) => {
expect(() =>