diff --git a/.github/workflows/openclaw-release-publish.yml b/.github/workflows/openclaw-release-publish.yml index f07c096d633c..4f9d49c82f5b 100644 --- a/.github/workflows/openclaw-release-publish.yml +++ b/.github/workflows/openclaw-release-publish.yml @@ -97,6 +97,11 @@ on: required: true default: false type: boolean + finalize_release_before_docker: + description: Explicitly activate the verified GitHub release before Docker publication (direct publication only) + required: false + default: false + type: boolean release_profile: description: Release coverage profile used for release evidence summaries; default reads it from the validation manifest required: false @@ -163,6 +168,8 @@ jobs: FOCUSED_RELEASE_EVIDENCE_RUN_ATTEMPT: ${{ inputs.focused_release_evidence_run_attempt }} OPENCLAW_NPM_RESUME_RUN_ID: ${{ inputs.openclaw_npm_resume_run_id }} PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }} + FINALIZE_RELEASE_BEFORE_DOCKER: ${{ inputs.finalize_release_before_docker }} + PREPARED_PLUGINS: ${{ inputs.prepared_plugins }} PUBLISH_DOCKER_ONLY: ${{ inputs.publish_docker_only && 'true' || 'false' }} PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }} PLUGINS: ${{ inputs.plugins }} @@ -251,6 +258,10 @@ jobs: echo "openclaw_npm_resume_run_id requires publish_openclaw_npm=true." >&2 exit 1 fi + if [[ "${FINALIZE_RELEASE_BEFORE_DOCKER:-false}" == "true" && ( "${PUBLISH_OPENCLAW_NPM}" != "true" || -n "${PREPARED_PLUGINS:-}" ) ]]; then + echo "finalize_release_before_docker requires direct publication with publish_openclaw_npm=true and no prepared_plugins." >&2 + exit 1 + fi if [[ "${PUBLISH_DOCKER_ONLY}" == "true" ]]; then if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then echo "publish_docker_only requires publish_openclaw_npm=false." >&2 @@ -1975,9 +1986,9 @@ jobs: append_release_proof_to_github_release record_postpublish_diagnostics assets-success if [[ "${failed}" == "0" ]]; then - echo "- GitHub release: kept draft until required publication checks complete" >> "$GITHUB_STEP_SUMMARY" + echo "- GitHub release: evidence complete; activation follows required publication checks" >> "$GITHUB_STEP_SUMMARY" else - echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY" + echo "- GitHub release: evidence updated; a required publish child failed" >> "$GITHUB_STEP_SUMMARY" fi fi if [[ "${failed}" != "0" ]]; then @@ -2054,8 +2065,14 @@ jobs: publish_docker: name: Publish Docker images - needs: [resolve_release_target, publish, verify_core_npm_registry] - if: ${{ always() && !contains(inputs.tag, '-alpha.') && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.verify_core_npm_registry.result == 'success')) }} + needs: + [ + resolve_release_target, + publish, + verify_core_npm_registry, + finalize_github_release_before_docker, + ] + if: ${{ always() && (!inputs.finalize_release_before_docker || needs.finalize_github_release_before_docker.result == 'success') && !contains(inputs.tag, '-alpha.') && ((inputs.publish_openclaw_npm && needs.publish.result == 'success') || (inputs.publish_docker_only && needs.verify_core_npm_registry.result == 'success')) }} uses: ./.github/workflows/docker-release.yml with: tag: ${{ inputs.tag }} @@ -2096,7 +2113,7 @@ jobs: name: Approve verified GitHub release activation # Approval waits must not hold the Linux updater publication queue. needs: [publish, publish_docker] - if: ${{ always() && inputs.prepared_plugins == '' && inputs.publish_openclaw_npm && needs.publish.result == 'success' && (needs.publish_docker.result == 'success' || (contains(inputs.tag, '-alpha.') && needs.publish_docker.result == 'skipped')) }} + if: ${{ always() && !inputs.finalize_release_before_docker && inputs.prepared_plugins == '' && inputs.publish_openclaw_npm && needs.publish.result == 'success' && (needs.publish_docker.result == 'success' || (contains(inputs.tag, '-alpha.') && needs.publish_docker.result == 'skipped')) }} runs-on: ubuntu-latest timeout-minutes: 10 environment: npm-release @@ -2109,11 +2126,12 @@ jobs: finalize_github_release: name: Finalize GitHub release + # Reconcile the same release after Docker; the opt-in early path reuses its approval. # Apps attach independently after npm and Gateway Docker evidence is complete. - needs: [publish, publish_docker, approve_github_release] + needs: [publish, publish_docker, approve_github_release, finalize_github_release_before_docker] # Prepared releases become visible in the outer button workflow, after this # parent succeeds and ClawHub's terminal publication can be verified. - if: ${{ always() && inputs.prepared_plugins == '' && inputs.publish_openclaw_npm && needs.publish.result == 'success' && (needs.publish_docker.result == 'success' || (contains(inputs.tag, '-alpha.') && needs.publish_docker.result == 'skipped')) && needs.approve_github_release.result == 'success' }} + if: ${{ always() && inputs.prepared_plugins == '' && inputs.publish_openclaw_npm && needs.publish.result == 'success' && (needs.publish_docker.result == 'success' || (contains(inputs.tag, '-alpha.') && needs.publish_docker.result == 'skipped')) && (needs.approve_github_release.result == 'success' || (inputs.finalize_release_before_docker && needs.finalize_github_release_before_docker.result == 'success')) }} runs-on: ubuntu-latest concurrency: group: linux-app-release-publish @@ -2122,7 +2140,7 @@ jobs: permissions: actions: read contents: write - steps: + steps: &finalize_github_release_steps - name: Checkout trusted updater publication tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -2176,12 +2194,37 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: linux-updater-continuity-${{ github.run_id }}-${{ github.run_attempt }} + name: linux-updater-continuity-${{ github.run_id }}-${{ github.run_attempt }}-${{ github.job }} path: | ${{ runner.temp }}/linux-updater-carry ${{ runner.temp }}/core-finalization.json if-no-files-found: ignore + approve_github_release_before_docker: + name: Approve GitHub release before Docker + needs: [publish] + if: ${{ inputs.finalize_release_before_docker && inputs.prepared_plugins == '' && inputs.publish_openclaw_npm && needs.publish.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: npm-release + permissions: {} + steps: + - run: echo "Approved the explicitly requested GitHub activation before Docker" + + finalize_github_release_before_docker: + name: Activate GitHub release before Docker + needs: [publish, approve_github_release_before_docker] + if: ${{ inputs.finalize_release_before_docker && needs.publish.result == 'success' && needs.approve_github_release_before_docker.result == 'success' }} + runs-on: ubuntu-latest + concurrency: + group: linux-app-release-publish + cancel-in-progress: false + queue: max + permissions: + actions: read + contents: write + steps: *finalize_github_release_steps + dispatch_linux_mirror: name: Dispatch legacy Linux mirror after publication needs: [resolve_release_target, finalize_github_release] diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 313f62a70a02..9ecdad5085db 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -455,7 +455,7 @@ For beta, stable, and full profiles, Linux (`ubuntu`) cross-OS lanes gate npm pu The helper uses the qualified npm artifact bound by Full Release Validation. Supply `--npm-preflight-run` only to recover a separately prepared historical release. It never silently rebuilds a missing qualified artifact. Docker publication consumes the prepared OCI artifacts after checking the finalized tag and exact producer tuple; only registry writes and selector promotion hold the publication lock. - `OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. It keeps the GitHub release as a draft while it verifies registry readback, calls `Docker Release` with the immutable tag and Release SHA for beta and stable releases, and only then finalizes the GitHub release. npm-only alpha releases finalize after the required npm checks without scheduling Docker. The release checkout remains the product/data root, while planning and final verification execute from the exact trusted workflow-source checkout so an older release commit cannot silently use obsolete release tooling. Once publication binds the frozen Tooling SHA to an exact protected lightweight `release-publish/<12sha>-` tag, that live tag-to-SHA mapping remains authoritative when `main` advances; the suffix records tag-creation provenance, not the current parent run id. Core and plugin npm publishers re-read that exact tag and revalidate the exact parent run tuple immediately before each npm publish or dist-tag mutation, failing closed on a missing, moved, annotated, or wrong-SHA tag, parent mismatch, or disallowed parent state. Other privileged writers require their dependent enforcement changes before the protected-tag publication route is globally complete. Before any publish child starts, it renders and caches the exact GitHub release body. When the complete selected `CHANGELOG/YYYY.M.PATCH.md` section fits GitHub's 125,000-character limit and the renderer's matching 125,000-byte safety ceiling, the page contains that exact `## YYYY.M.PATCH` section including its heading. When the source section does not fit, the page keeps the exact grouped editorial notes and replaces the oversized contribution record with a stable link to the full record in the tag-pinned `CHANGELOG/records/YYYY.M.PATCH.md` (historical monolithic tags retain their original record link); partial records and truncated bullets are never published. The workflow chooses that full or compact body before adding `### Release verification`; if the proof tail would exceed the limit, it keeps the canonical body and relies on the immutable attached evidence instead. Stable releases published to npm `latest` become the GitHub latest release, while stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, creates the draft GitHub release page up front, runs native Android qualification independently for a matching tagged Android pin (otherwise recording an explicit skip and shared mobile cutter remedy) and dispatches its publisher after the npm publisher succeeds without making GitHub finalization wait, waits for ClawHub staging only when `wait_for_clawhub=true` (the default `false` leaves that child detached), then runs the trusted-main beta verifier and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, staged ClawHub child workflow run IDs, and optional NPM Telegram run ID. The ClawHub bootstrap verifier requires the exact trusted-main workflow path and SHA, producer and terminal run attempts, release SHA, requested package set, immutable package artifact tuple, and terminal registry readback artifact; a successful legacy release-ref run is not accepted. + `OpenClaw Release Publish` dispatches the selected or all-publishable plugin packages to npm and the same set to ClawHub in parallel, then promotes the prepared OpenClaw npm preflight artifact with the matching dist-tag once plugin npm publish succeeds. By default, it keeps the GitHub release as a draft while it verifies registry readback, calls `Docker Release` with the immutable tag and Release SHA for beta and stable releases, and only then finalizes the GitHub release. npm-only alpha releases finalize after the required npm checks without scheduling Docker. The release checkout remains the product/data root, while planning and final verification execute from the exact trusted workflow-source checkout so an older release commit cannot silently use obsolete release tooling. Once publication binds the frozen Tooling SHA to an exact protected lightweight `release-publish/<12sha>-` tag, that live tag-to-SHA mapping remains authoritative when `main` advances; the suffix records tag-creation provenance, not the current parent run id. Core and plugin npm publishers re-read that exact tag and revalidate the exact parent run tuple immediately before each npm publish or dist-tag mutation, failing closed on a missing, moved, annotated, or wrong-SHA tag, parent mismatch, or disallowed parent state. Other privileged writers require their dependent enforcement changes before the protected-tag publication route is globally complete. Before any publish child starts, it renders and caches the exact GitHub release body. When the complete selected `CHANGELOG/YYYY.M.PATCH.md` section fits GitHub's 125,000-character limit and the renderer's matching 125,000-byte safety ceiling, the page contains that exact `## YYYY.M.PATCH` section including its heading. When the source section does not fit, the page keeps the exact grouped editorial notes and replaces the oversized contribution record with a stable link to the full record in the tag-pinned `CHANGELOG/records/YYYY.M.PATCH.md` (historical monolithic tags retain their original record link); partial records and truncated bullets are never published. The workflow chooses that full or compact body before adding `### Release verification`; if the proof tail would exceed the limit, it keeps the canonical body and relies on the immutable attached evidence instead. Stable releases published to npm `latest` become the GitHub latest release, while stable maintenance releases kept on npm `beta` are created with GitHub `latest=false`. The workflow also uploads the preflight dependency evidence, the full-validation manifest, and postpublish registry verification evidence to the GitHub release for post-release incident response. It prints child run IDs immediately, auto-approves release environment gates the workflow token is allowed to approve, summarizes failed child jobs with log tails, creates the draft GitHub release page up front, runs native Android qualification independently for a matching tagged Android pin (otherwise recording an explicit skip and shared mobile cutter remedy) and dispatches its publisher after the npm publisher succeeds without making GitHub finalization wait, waits for ClawHub staging only when `wait_for_clawhub=true` (the default `false` leaves that child detached), then runs the trusted-main beta verifier and uploads postpublish evidence for the GitHub release, npm package, selected plugin npm packages, staged ClawHub child workflow run IDs, and optional NPM Telegram run ID. The ClawHub bootstrap verifier requires the exact trusted-main workflow path and SHA, producer and terminal run attempts, release SHA, requested package set, immutable package artifact tuple, and terminal registry readback artifact; a successful legacy release-ref run is not accepted. Core npm dispatch and environment approval start as soon as plugin npm succeeds. Once the exact `npm-release` approval succeeds, the parent proceeds without waiting for core runner allocation. ClawHub inventory authorization and optional bootstrap completion can overlap the running core publish. A failed ClawHub authorization still fails the parent and leaves the GitHub release as a draft; the parent collects any already-started core result and records its evidence. @@ -1159,7 +1159,7 @@ release closeout. Full Release Validation and qualified package artifacts must a 4. Dispatch `Plugin NPM Release` with `publish_scope=all-publishable` and `ref=`. 5. Dispatch `Plugin ClawHub Release` with the same scope and SHA. 6. After plugin npm succeeds, dispatch `OpenClaw NPM Release` with the release tag, npm dist-tag, and saved `preflight_run_id` after verifying the saved `full_release_validation_run_id` and exact run attempt. ClawHub proceeds in parallel. -7. Verify the published npm package and selector readback, then call reusable `Docker Release` with the immutable tag and SHA. Finalize the draft GitHub release after npm and Docker evidence succeeds; Docker remains part of the Gateway distribution. +7. Verify the published npm package and selector readback, then call reusable `Docker Release` with the immutable tag and SHA. By default, finalize the draft GitHub release after npm and Docker evidence succeeds. The explicitly requested `finalize_release_before_docker=true` fast path activates after npm verification and evidence uploads, then publishes Docker; Docker remains part of the Gateway distribution. 8. For stable, optionally dispatch `Windows Node Release` after finalization with both `windows_node_tag` and candidate-approved `windows_node_installer_digests`. It attaches signed installers and checksums to the public release as a detached child. Omit both inputs to skip Windows dispatch. When the tagged `apps/android/version.json` matches the release train, qualify and dispatch `Android Release` independently for its exact-tag signed APK, checksum, and provenance; run macOS validation/preflight/publish through `openclaw/releases` in parallel or afterward. No app workflow delays npm or GitHub release finalization. Track app failures through their summaries and evidence, then recover only the failed platform. The Android train is pinned independently. If its tagged version differs from @@ -1232,6 +1232,24 @@ gh workflow run openclaw-release-publish.yml \ Include `plugin_sdk_api_acknowledgement` only when the npm preflight's Plugin SDK API report contains changes. +An already-public GitHub release can be resumed with the same frozen inputs. +The publisher verifies its canonical notes and any recorded release SHA, leaves +the public page visible, and completes missing evidence assets after registry +verification. Existing immutable evidence must match; changed notes, conflicting +assets, or a body already handed to the post-docs publisher stop the initial +publisher. Finalization preserves the requested `make_latest` behavior and never +moves latest back from a newer release. + +When the operator explicitly wants the release page visible before Docker, +add `-f finalize_release_before_docker=true` to the direct publication command. +The default is `false`. This path still requires successful npm publication, +registry verification, evidence uploads, and one activation environment approval; +it activates the page before starting Docker. Docker remains required for the +parent to finish successfully. If Docker then fails, the page stays public and +the Docker-only recovery command below completes the missing distribution. +This input requires `publish_openclaw_npm=true` and cannot be combined with +`prepared_plugins`; prepared releases retain the button's final visibility owner. + If a beta or regular stable package is already published but its container images are missing, do not rerun npm or plugin publication. Reuse the immutable release tag plus its successful npm preflight and Full Release Validation evidence through the @@ -1421,6 +1439,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 pinned `sha256:` digests; required only when `windows_node_tag` is supplied - `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` +- `finalize_release_before_docker`: explicit direct-publication fast path; default `false`. Activates the verified GitHub release before Docker, preserving the same environment approval and latest policy. Requires `publish_openclaw_npm=true` and no `prepared_plugins`. Docker failure leaves the release public for Docker-only recovery. - `publish_docker_only`: beta, regular stable (`latest`), or extended-stable recovery/closeout path. It requires `publish_openclaw_npm=false`, complete preflight and Full Release Validation evidence, then verifies the exact npm package, selected dist-tag, and tarball digest before invoking Docker publication. - `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` diff --git a/scripts/lib/release-publish-children.sh b/scripts/lib/release-publish-children.sh index a08c17f31c61..0f18209fd5cf 100644 --- a/scripts/lib/release-publish-children.sh +++ b/scripts/lib/release-publish-children.sh @@ -532,60 +532,34 @@ approve_clawhub_bootstrap_environments() { } guard_existing_public_release() { - local release_version asset_name release_json is_draft has_sha has_proof has_asset has_canonical_body release_url release_body release_body_file + local release_json release_body release_body_file if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then return 0 fi - - if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then + if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,body 2>/dev/null)"; then return 0 fi release_body="$(printf '%s' "${release_json}" | jq -er '.body | strings')" || return 1 assert_initial_release_body "${release_body}" || return 1 - - is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')" - if [[ "${is_draft}" == "true" ]]; then + if [[ "$(printf '%s' "${release_json}" | jq -r '.isDraft')" == "true" ]]; then return 0 fi - release_version="${RELEASE_TAG#v}" - asset_name="openclaw-${release_version}-dependency-evidence.zip" - has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')" - has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')" - has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')" - release_url="$(printf '%s' "${release_json}" | jq -r '.url')" - release_body="$(printf '%s' "${release_json}" | jq -r '.body')" release_body_file="${RUNNER_TEMP}/existing-public-release-body.md" printf '%s' "${release_body}" > "${release_body_file}" - has_canonical_body="false" - if canonical_release_body_matches "${release_body_file}"; then - has_canonical_body="true" + if ! canonical_release_body_matches "${release_body_file}"; then + echo "Public release notes are no longer canonical; refusing to overwrite them." >&2 + return 1 fi - - if [[ "${has_asset}" == "true" && - "${has_sha}" == "true" && - "${has_proof}" == "true" && - "${has_canonical_body}" == "true" ]]; then - return 0 + if [[ "${release_body}" != "$(cat "${prepared_release_notes_file}")" ]] && + ! grep -Fqx -- "- release SHA: \`${TARGET_SHA}\`" "${release_body_file}"; then + echo "Public release verification does not match release SHA ${TARGET_SHA}." >&2 + return 1 fi - - # The renderer omits the verification tail when the canonical body - # already reaches GitHub's limit. A canonical proofless body with - # intact dependency evidence is retry-safe: postpublish re-attempts - # the proof append on this run. - if [[ "${has_asset}" == "true" && - "${has_canonical_body}" == "true" && - "${has_proof}" != "true" ]]; then - return 0 - fi - - { - echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}." - echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}" - echo "Create a new beta tag or delete/draft the incomplete public release before retrying." - } >&2 - exit 1 + # A partial public release is resumable. The upload owner compares existing + # immutable evidence and attaches missing assets after registry verification. + echo "- GitHub release: resuming canonical public page; evidence will be verified and completed" >> "$GITHUB_STEP_SUMMARY" } resolve_openclaw_npm_publish_state() { diff --git a/test/scripts/ci-workflow-guards.test.ts b/test/scripts/ci-workflow-guards.test.ts index 87123a38ee1f..6418acb3d38f 100644 --- a/test/scripts/ci-workflow-guards.test.ts +++ b/test/scripts/ci-workflow-guards.test.ts @@ -19624,10 +19624,16 @@ it.each(["publish", "promote"])( runInNewContext(finalize.if.replace(/^\$\{\{|\}\}$/gu, ""), { always: () => true, contains: (value: string, part: string) => value.includes(part), - inputs: { tag: "v2026.9.4", prepared_plugins: "", publish_openclaw_npm: true }, + inputs: { + tag: "v2026.9.4", + prepared_plugins: "", + publish_openclaw_npm: true, + finalize_release_before_docker: false, + }, needs: { publish: { result: "success" }, publish_docker: { result: "success" }, + finalize_github_release_before_docker: { result: "skipped" }, verify: { result: "success" }, [approvalId]: { result }, }, diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index b9ea37e91763..e71371b8ef95 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -4915,6 +4915,27 @@ describe("package acceptance workflow", () => { expect(result.status, result.stderr).toBe(0); }); + it.each([ + { core: "true", prepared: "", allowed: true }, + { core: "false", prepared: "", allowed: false }, + { core: "true", prepared: '{"npm":{},"clawhub":{}}', allowed: false }, + ])( + "admits early GitHub activation only for direct core publication: $core/$prepared", + ({ core, prepared, allowed }) => { + const result = runReleasePublishInputValidation({ + FINALIZE_RELEASE_BEFORE_DOCKER: "true", + PUBLISH_OPENCLAW_NPM: core, + PREPARED_PLUGINS: prepared, + }); + expect(result.status, result.stderr).toBe(allowed ? 0 : 1); + if (!allowed) { + expect(result.stderr).toContain( + "finalize_release_before_docker requires direct publication", + ); + } + }, + ); + it("allows Docker-only recovery for beta, stable, and extended-stable releases", () => { for (const release of [ { distTag: "beta", tag: "v2026.8.1-beta.2" }, @@ -6069,7 +6090,8 @@ render_github_release_notes() { cp "$2" "$1"; printf '%s\\n' '{"verificationIncl ); expect(events).toContain("release-evidence"); expect(events).not.toContain("windows"); - expect(fixture.summary()).toContain("left as draft"); + expect(fixture.summary()).toContain("evidence updated; a required publish child failed"); + expect(fixture.summary()).not.toContain("left as draft"); }, ); @@ -6671,6 +6693,7 @@ wait_for_run openclaw-npm-release.yml 404 "$EXPECTED_SHA" "$STARTED_JOB" "$APPRO "publish", "publish_docker", "approve_github_release", + "finalize_github_release_before_docker", ]); expect(nativeJob["continue-on-error"]).toBe(true); expect(androidJob["continue-on-error"]).toBe(true); @@ -13922,7 +13945,12 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`, expect(createReleaseIndex).toBeGreaterThanOrEqual(0); expect(verifyReleaseIndex).toBeGreaterThan(createReleaseIndex); expect(appendProofIndex).toBeGreaterThan(verifyReleaseIndex); - expect(finalizeJob.needs).toEqual(["publish", "publish_docker", "approve_github_release"]); + expect(finalizeJob.needs).toEqual([ + "publish", + "publish_docker", + "approve_github_release", + "finalize_github_release_before_docker", + ]); expect(finalizeJob.if).toContain("needs.publish_docker.result == 'success'"); expect(finalizeJob.if).toContain("inputs.prepared_plugins == ''"); expect(finalizeJob.if).toContain("needs.approve_github_release.result == 'success'"); @@ -14132,7 +14160,12 @@ printf '%s\\n' "$DEEPSEEK_API_KEY" "$DEEPINFRA_API_KEY"`, expect(workflow.on?.workflow_dispatch?.inputs?.[input]).toMatchObject({ required: false }); } expect(publish.needs).toEqual(["resolve_release_target"]); - expect(finalize.needs).toEqual(["publish", "publish_docker", "approve_github_release"]); + expect(finalize.needs).toEqual([ + "publish", + "publish_docker", + "approve_github_release", + "finalize_github_release_before_docker", + ]); expect(windows.needs).toEqual(["resolve_release_target", "finalize_github_release"]); expect(windows["continue-on-error"]).toBe(true); expect(windows.if).toContain("needs.finalize_github_release.result == 'success'"); diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index d6651215c687..b32365600712 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -1994,6 +1994,7 @@ describe("release validation no-push transport", () => { "resolve_release_target", "publish", "verify_core_npm_registry", + "finalize_github_release_before_docker", ]); expect(dockerCall.if).toContain("needs.publish.result == 'success'"); expect(dockerCall.if).toContain("needs.verify_core_npm_registry.result == 'success'"); @@ -2036,6 +2037,7 @@ describe("release validation no-push transport", () => { "publish", "publish_docker", "approve_github_release", + "finalize_github_release_before_docker", ]); const identity = step( @@ -2051,8 +2053,15 @@ describe("release validation no-push transport", () => { expect(reusablePermissionViolations(DOCKER_RELEASE, "prepare")).toEqual([]); }); - it("finalizes npm-only alpha releases while retaining required Docker gates for other trains", () => { + it("keeps Docker required by default and activates early only after explicit approval", () => { const workflow = readWorkflow(".github/workflows/openclaw-release-publish.yml"); + expect(workflow.on?.workflow_dispatch?.inputs?.finalize_release_before_docker).toMatchObject({ + type: "boolean", + default: false, + }); + const early = job(workflow, "finalize_github_release_before_docker"); + expect(early.needs).toEqual(["publish", "approve_github_release_before_docker"]); + expect(early.steps).toEqual(job(workflow, "finalize_github_release").steps); const cases = [ { tag: "v2026.9.1-alpha.1", @@ -2090,14 +2099,52 @@ describe("release validation no-push transport", () => { publishDocker: false, finalize: false, }, + { + tag: "v2026.9.1", + npm: "success", + docker: "failure", + beforeDocker: true, + early: "success", + publishDocker: true, + finalize: false, + }, + { + tag: "v2026.9.1", + npm: "success", + docker: "success", + beforeDocker: true, + early: "success", + publishDocker: true, + finalize: true, + }, + { + tag: "v2026.9.1", + npm: "failure", + docker: "skipped", + beforeDocker: true, + early: "skipped", + publishDocker: false, + finalize: false, + }, + { + tag: "v2026.9.1", + npm: "success", + docker: "skipped", + beforeDocker: true, + early: "failure", + publishDocker: false, + finalize: false, + }, ]; for (const scenario of cases) { + const beforeDocker = scenario.beforeDocker === true; const evaluate = (name: string, preparedPlugins = "") => runInNewContext(job(workflow, name).if!.slice(3, -2), { always: () => true, contains: (value: string, search: string) => value.includes(search), inputs: { tag: scenario.tag, + finalize_release_before_docker: beforeDocker, publish_openclaw_npm: true, publish_docker_only: false, prepared_plugins: preparedPlugins, @@ -2105,10 +2152,19 @@ describe("release validation no-push transport", () => { needs: { publish: { result: scenario.npm }, publish_docker: { result: scenario.docker }, - approve_github_release: { result: "success" }, + approve_github_release: { result: beforeDocker ? "skipped" : "success" }, + approve_github_release_before_docker: { result: beforeDocker ? "success" : "skipped" }, + finalize_github_release_before_docker: { result: scenario.early ?? "skipped" }, verify_core_npm_registry: { result: "skipped" }, }, }); + expect(evaluate("approve_github_release_before_docker")).toBe( + beforeDocker && scenario.npm === "success", + ); + expect(evaluate("approve_github_release")).toBe(!beforeDocker && scenario.finalize); + expect(evaluate("finalize_github_release_before_docker")).toBe( + beforeDocker && scenario.npm === "success", + ); expect(evaluate("publish_docker"), JSON.stringify(scenario)).toBe(scenario.publishDocker); expect(evaluate("finalize_github_release"), JSON.stringify(scenario)).toBe(scenario.finalize); expect(evaluate("finalize_github_release", '{"npm":{},"clawhub":{}}')).toBe(false); diff --git a/test/scripts/release-publish-draft.test.ts b/test/scripts/release-publish-draft.test.ts index 12492099e2a7..e34121ac716e 100644 --- a/test/scripts/release-publish-draft.test.ts +++ b/test/scripts/release-publish-draft.test.ts @@ -16,7 +16,7 @@ import { createScriptTestHarness } from "./test-helpers.js"; const { createTempDir } = createScriptTestHarness(); -it("renders and verifies an old pinned target using trusted publication tooling", () => { +function publicationFixture() { const root = realpathSync(createTempDir("release-publish-historical-tooling-")); const repository = resolve("."); mkdirSync(join(root, "scripts")); @@ -59,6 +59,11 @@ it("renders and verifies an old pinned target using trusted publication tooling" copyFileSync(join(repository, source), join(root, ".release-harness", source)); } symlinkSync(join(repository, "node_modules"), join(root, "node_modules"), "dir"); + return { root, repository, targetSha }; +} + +it("renders and verifies an old pinned target using trusted publication tooling", () => { + const { root, repository, targetSha } = publicationFixture(); const workflow = parse( readFileSync(join(repository, ".github/workflows/openclaw-release-publish.yml"), "utf8"), ); @@ -222,3 +227,89 @@ gh() { expect(existsSync(commands)).toBe(false); }, ); + +it.each([ + { state: "missing", body: "canonical", error: undefined }, + { state: "matching", body: "proof", error: undefined }, + { state: "different", body: "canonical", error: "differs from this release run" }, + { state: "missing", body: "different", error: "Public release notes are no longer canonical" }, + { state: "missing", body: "wrong-sha", error: "does not match" }, +])("resumes a public release with $state evidence and $body notes", ({ state, body, error }) => { + const { root, targetSha } = publicationFixture(); + const notes = "## 2026.9.4\n\n### Fixes\n\n- Frozen release fix."; + const releaseBody = + body === "different" + ? "Unrelated release notes" + : body === "proof" || body === "wrong-sha" + ? `${notes}\n\n### Release verification\n\n- release SHA: \`${body === "wrong-sha" ? "b".repeat(40) : targetSha}\`` + : notes; + const assetName = "openclaw-2026.9.4-release-manifest.json"; + writeFileSync( + join(root, "release.json"), + JSON.stringify({ + isDraft: false, + body: releaseBody, + assets: state === "missing" ? [] : [{ name: assetName }], + url: "https://github.com/fixture/repository/releases/tag/v2026.9.4", + }), + ); + writeFileSync(join(root, "manifest.json"), '{"source":"frozen"}'); + writeFileSync( + join(root, "existing.json"), + state === "different" ? '{"source":"changed"}' : '{"source":"frozen"}', + ); + const result = spawnSync( + "bash", + [ + "-c", + ` +source "$GITHUB_WORKSPACE/.release-harness/scripts/lib/release-publish-children.sh" +verify_release_tag_target() { :; } +gh() { + case "$1 $2" in + "release view") cat "$RUNNER_TEMP/release.json" ;; + "release download") + local destination="" + while (( $# > 0 )); do + if [[ "$1" == --dir ]]; then destination="$2"; break; fi + shift + done + cp "$RUNNER_TEMP/existing.json" "$destination/$ASSET_NAME" + ;; + "release upload") printf '%s\\n' "$@" >> "$RUNNER_TEMP/uploads" ;; + *) echo "Unexpected mutation: $*" >&2; return 1 ;; + esac +} +prepared_release_notes_file="$RUNNER_TEMP/prepared.md" +render_github_release_notes "$prepared_release_notes_file" +guard_existing_public_release +create_or_update_github_release +attach_or_verify_release_asset "$RUNNER_TEMP/manifest.json" "$ASSET_NAME" +`, + ], + { + cwd: root, + encoding: "utf8", + env: { + ...createNestedGitEnv(), + GITHUB_WORKSPACE: root, + RUNNER_TEMP: root, + GITHUB_STEP_SUMMARY: join(root, "summary"), + GITHUB_REPOSITORY: "fixture/repository", + RELEASE_TAG: "v2026.9.4", + TARGET_SHA: targetSha, + GITHUB_REF: "refs/tags/release-publish/aaaaaaaaaaaa-1", + PARENT_WORKFLOW_SHA: "a".repeat(40), + PUBLISH_OPENCLAW_NPM: "true", + RELEASE_NPM_DIST_TAG: "latest", + ASSET_NAME: assetName, + }, + }, + ); + expect(result.status, result.stderr).toBe(error ? 1 : 0); + if (error) { + expect(result.stderr).toContain(error); + } + expect(existsSync(join(root, "uploads"))).toBe(state === "missing" && !error); + expect(JSON.parse(readFileSync(join(root, "release.json"), "utf8")).body).toBe(releaseBody); +}); diff --git a/test/scripts/vercel-container-registry-publish.test.ts b/test/scripts/vercel-container-registry-publish.test.ts index 6930a8590e5a..6f59902b602c 100644 --- a/test/scripts/vercel-container-registry-publish.test.ts +++ b/test/scripts/vercel-container-registry-publish.test.ts @@ -913,7 +913,12 @@ describe("Vercel Container Registry publishing", () => { expect(releasePublish.secrets).toEqual({ VERCEL_TOKEN: "${{ secrets.VERCEL_TOKEN }}", }); - expect(finalizeRelease.needs).toEqual(["publish", "publish_docker", "approve_github_release"]); + expect(finalizeRelease.needs).toEqual([ + "publish", + "publish_docker", + "approve_github_release", + "finalize_github_release_before_docker", + ]); expect(finalizeRelease.if).not.toContain("publish_vcr"); expect(recoveryValidation.if).toBe("${{ !inputs.advisory }}"); expect(recoveryValidation.permissions).toEqual({});