mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
ci: simplify extended-stable release closeout
This commit is contained in:
@@ -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>`
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
Reference in New Issue
Block a user