From f1f5a3fcf4d69388d5c705b4fab84e5c461933e2 Mon Sep 17 00:00:00 2001 From: Onur Date: Wed, 1 Apr 2026 19:24:37 +0200 Subject: [PATCH] Release: trim duplicate preflight work (#59117) * Release: skip duplicate runtime-deps staging * Release: trim public mac validation workflow * Release: require promoted npm publish * Release: verify promoted npm provenance * Release: restore public mac validation build * Release: skip pack check on npm promote * Release: skip pack check on npm promote --- .../openclaw-release-maintainer/SKILL.md | 52 ++++++----- .github/workflows/macos-release.yml | 9 +- .github/workflows/openclaw-npm-release.yml | 88 +++++++++++++++---- package.json | 2 +- scripts/openclaw-npm-release-check.ts | 14 ++- test/openclaw-npm-release-check.test.ts | 23 +++++ 6 files changed, 140 insertions(+), 48 deletions(-) diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index 22f4b95d059..750983e82a7 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -120,8 +120,11 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts - The npm workflow and the private mac publish workflow accept `preflight_only=true` to run validation/build/package steps without uploading public release assets. -- Both workflows also accept a prior successful preflight run id so a real - publish can promote the prepared artifacts without rebuilding them again. +- Real npm publish requires a prior successful npm preflight run id so the + publish job promotes the prepared tarball instead of rebuilding it. +- Real private mac publish requires a prior successful private mac preflight + run id so the publish job promotes the prepared artifacts instead of + rebuilding or renotarizing them again. - The private mac workflow also accepts `smoke_test_only=true` for branch-safe workflow smoke tests that use ad-hoc signing, skip notarization, skip shared appcast generation, and do not prove release readiness. @@ -132,17 +135,23 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts workflow change before merge. - `.github/workflows/macos-release.yml` in `openclaw/openclaw` is now a public validation-only handoff. It validates the tag/release state and points - operators to the private repo; it does not build or publish macOS artifacts. + operators to the private repo. It still rebuilds the JS outputs needed for + release validation, but it does not sign, notarize, or publish macOS + artifacts. +- `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml` + is the required private mac validation lane for `swift test`; keep it green + before any real mac publish run starts. - Real mac preflight and real mac publish both use `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml`. -- The private mac workflow runs on GitHub's xlarge macOS runner and uses a - SwiftPM cache because the Swift build/test/package path is CPU-heavy. +- The private mac validation lane runs on GitHub's standard macOS runner. +- The private mac preflight path runs on GitHub's xlarge macOS runner and uses + a SwiftPM cache because the build/sign/notarize/package path is CPU-heavy. - Private mac preflight uploads notarized build artifacts as workflow artifacts instead of uploading public GitHub release assets. - Private smoke-test runs upload ad-hoc, non-notarized build artifacts as workflow artifacts and intentionally skip stable `appcast.xml` generation. -- npm preflight, public mac validation, and private mac preflight must all pass - before any real publish run starts. +- npm preflight, public mac validation, private mac validation, and private mac + preflight must all pass before any real publish run starts. - Real publish runs must be dispatched from `main`; branch-dispatched publish attempts should fail before the protected environment is reached. - The release workflows stay tag-based; rely on the documented release sequence @@ -150,8 +159,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts - The `npm-release` environment must be approved by `@openclaw/openclaw-release-managers` before publish continues. - Mac publish uses `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` for - build, signing, notarization, packaged mac artifact generation, and - stable-feed `appcast.xml` artifact generation. + private mac preflight artifact preparation and real publish artifact + promotion. - Real private mac publish uploads the packaged `.zip`, `.dmg`, and `.dSYM.zip` assets to the existing GitHub release in `openclaw/openclaw` automatically when `OPENCLAW_PUBLIC_REPO_RELEASE_TOKEN` is present in the @@ -209,29 +218,28 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts 7. Create and push the git tag. 8. Create or refresh the matching GitHub release. 9. Start `.github/workflows/openclaw-npm-release.yml` with `preflight_only=true` - and wait for it to pass. Save that run id if you want the real publish to - reuse the prepared npm tarball. + and wait for it to pass. Save that run id because the real publish requires + it to reuse the prepared npm tarball. 10. Start `.github/workflows/macos-release.yml` in `openclaw/openclaw` and wait for the public validation-only run to pass. 11. Start + `openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml` + with the same tag and wait for the private mac validation lane to pass. +12. Start `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` - with `preflight_only=true` and wait for it to pass. Save that run id if you - want the real publish to reuse the notarized mac artifacts. -12. If any preflight or validation run fails, fix the issue on a new commit, + with `preflight_only=true` and wait for it to pass. Save that run id because + the real publish requires it to reuse the notarized mac artifacts. +13. If any preflight or validation run fails, fix the issue on a new commit, delete the tag and matching GitHub release, recreate them from the fixed commit, and rerun all relevant preflights from scratch before continuing. Never reuse old preflight results after the commit changes. -13. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for - the real publish. When the preflight run id is available, pass it via - `preflight_run_id` to skip the second npm rebuild. -14. Start the real private mac publish with the same tag. When the private - preflight run id is available, pass it via `preflight_run_id` to skip the - second mac build/sign/notarize cycle and promote those prepared artifacts - directly to the public release. +14. Start `.github/workflows/openclaw-npm-release.yml` with the same tag for + the real publish and pass the successful npm `preflight_run_id`. 15. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`. 16. Start `openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml` - for the real publish and wait for success. + for the real publish with the successful private mac `preflight_run_id` and + wait for success. 17. Verify the successful real private mac run uploaded the `.zip`, `.dmg`, and `.dSYM.zip` artifacts to the existing GitHub release in `openclaw/openclaw`. diff --git a/.github/workflows/macos-release.yml b/.github/workflows/macos-release.yml index 39a153fa6da..6ecfcdc336f 100644 --- a/.github/workflows/macos-release.yml +++ b/.github/workflows/macos-release.yml @@ -82,11 +82,12 @@ jobs: { echo "## Public macOS validation only" echo - echo "This workflow no longer builds, signs, notarizes, or uploads macOS assets." + echo "This workflow validates the public release handoff and still builds JS artifacts needed for release checks." + echo "It does not sign, notarize, or upload macOS assets." echo echo "Next step:" - echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\`." - echo "- Use \`preflight_only=true\` there for the full private mac preflight." - echo "- For the real publish path, the private run uploads the packaged \`.zip\`, \`.dmg\`, and \`.dSYM.zip\` files to the existing GitHub release in \`openclaw/openclaw\` automatically." + echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-validate.yml\` with tag \`${RELEASE_TAG}\` and wait for the private mac validation lane to pass." + echo "- Run \`openclaw/releases-private/.github/workflows/openclaw-macos-publish.yml\` with tag \`${RELEASE_TAG}\` and \`preflight_only=true\` for the full private mac preflight." + echo "- For the real publish path, run the same private mac publish workflow from \`main\` with the successful private preflight \`preflight_run_id\` so it promotes the prepared artifacts instead of rebuilding them." echo "- For stable releases, also download \`macos-appcast-${RELEASE_TAG}\` from the successful private run and commit \`appcast.xml\` back to \`main\` in \`openclaw/openclaw\`." } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index 23e33500a44..1ed9a14b5ef 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -13,7 +13,7 @@ on: default: false type: boolean preflight_run_id: - description: Existing preflight workflow run id to promote without rebuilding + description: Existing successful preflight workflow run id to promote without rebuilding required: false type: string @@ -28,6 +28,7 @@ env: jobs: preflight_openclaw_npm: + if: ${{ inputs.preflight_only }} runs-on: ubuntu-latest permissions: contents: read @@ -42,6 +43,12 @@ jobs: exit 1 fi + - name: Forbid preflight artifact promotion on validation-only runs + if: ${{ inputs.preflight_only && inputs.preflight_run_id != '' }} + run: | + echo "preflight_run_id is only valid for real publish runs." + exit 1 + - name: Checkout uses: actions/checkout@v6 with: @@ -88,6 +95,7 @@ jobs: - name: Validate release tag and package metadata if: ${{ inputs.preflight_run_id == '' }} env: + OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1" RELEASE_TAG: ${{ inputs.tag }} RELEASE_MAIN_REF: origin/main run: | @@ -106,6 +114,7 @@ jobs: id: packed_tarball env: OPENCLAW_PREPACK_PREPARED: "1" + RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail PACK_JSON="$(npm pack --json)" @@ -115,16 +124,23 @@ jobs: echo "npm pack did not produce a tarball file." >&2 exit 1 fi - echo "path=$PACK_PATH" >> "$GITHUB_OUTPUT" + RELEASE_SHA="$(git rev-parse HEAD)" + ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight" + rm -rf "$ARTIFACT_DIR" + mkdir -p "$ARTIFACT_DIR" + cp "$PACK_PATH" "$ARTIFACT_DIR/" + printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt" + printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt" + echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT" - - name: Upload prepared npm tarball + - name: Upload prepared npm publish bundle uses: actions/upload-artifact@v7 with: name: openclaw-npm-preflight-${{ inputs.tag }} - path: ${{ steps.packed_tarball.outputs.path }} + path: ${{ steps.packed_tarball.outputs.dir }} if-no-files-found: error - validate_publish_dispatch_ref: + validate_publish_request: if: ${{ !inputs.preflight_only }} runs-on: ubuntu-latest permissions: @@ -140,9 +156,19 @@ jobs: exit 1 fi + - name: Require preflight artifact promotion on real publish + env: + PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + run: | + set -euo pipefail + if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then + echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2 + exit 1 + fi + publish_openclaw_npm: # npm trusted publishing + provenance requires a GitHub-hosted runner. - needs: [preflight_openclaw_npm, validate_publish_dispatch_ref] + needs: [validate_publish_request] if: ${{ !inputs.preflight_only }} runs-on: ubuntu-latest environment: npm-release @@ -187,8 +213,16 @@ jobs: echo "Publishing openclaw@${PACKAGE_VERSION}" + - name: Verify preflight run metadata + env: + GH_TOKEN: ${{ github.token }} + PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }} + run: | + set -euo pipefail + RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)" + printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? ""}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`);' + - name: Download prepared npm tarball - if: ${{ inputs.preflight_run_id != '' }} uses: actions/download-artifact@v8 with: name: openclaw-npm-preflight-${{ inputs.tag }} @@ -197,17 +231,10 @@ jobs: run-id: ${{ inputs.preflight_run_id }} github-token: ${{ github.token }} - - name: Build - if: ${{ inputs.preflight_run_id == '' }} - run: pnpm build - - - name: Build Control UI - if: ${{ inputs.preflight_run_id == '' }} - run: pnpm ui:build - - name: Validate release tag and package metadata if: ${{ inputs.preflight_run_id == '' }} env: + OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1" RELEASE_TAG: ${{ inputs.tag }} RELEASE_MAIN_REF: origin/main run: | @@ -219,12 +246,35 @@ jobs: git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main pnpm release:openclaw:npm:check - - name: Resolve publish tarball - id: publish_tarball - if: ${{ inputs.preflight_run_id != '' }} + - name: Verify prepared tarball provenance + env: + RELEASE_TAG: ${{ inputs.tag }} run: | set -euo pipefail - TARBALL_PATH="$(find preflight-tarball -maxdepth 1 -type f -name '*.tgz' -print | sort | tail -n 1)" + EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)" + TAG_FILE="preflight-tarball/release-tag.txt" + SHA_FILE="preflight-tarball/release-sha.txt" + if [[ ! -f "$TAG_FILE" || ! -f "$SHA_FILE" ]]; then + echo "Prepared preflight metadata is missing." >&2 + ls -la preflight-tarball >&2 || true + exit 1 + fi + ARTIFACT_RELEASE_TAG="$(tr -d '\r\n' < "$TAG_FILE")" + ARTIFACT_RELEASE_SHA="$(tr -d '\r\n' < "$SHA_FILE")" + if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then + echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2 + exit 1 + fi + if [[ "$ARTIFACT_RELEASE_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then + echo "Prepared preflight SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $ARTIFACT_RELEASE_SHA" >&2 + exit 1 + fi + + - name: Resolve publish tarball + id: publish_tarball + run: | + set -euo pipefail + TARBALL_PATH="$(find preflight-tarball -type f -name '*.tgz' -print | sort | tail -n 1)" if [[ -z "$TARBALL_PATH" ]]; then echo "Prepared preflight tarball not found." >&2 ls -la preflight-tarball >&2 || true diff --git a/package.json b/package.json index 43eb9b3cade..36c58316af7 100644 --- a/package.json +++ b/package.json @@ -1127,7 +1127,7 @@ "protocol:check": "pnpm protocol:gen && pnpm protocol:gen:swift && git diff --exit-code -- dist/protocol.schema.json apps/macos/Sources/OpenClawProtocol/GatewayModels.swift apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift", "protocol:gen": "node --import tsx scripts/protocol-gen.ts", "protocol:gen:swift": "node --import tsx scripts/protocol-gen-swift.ts", - "release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:facades:check && pnpm plugin-sdk:api:check && node scripts/stage-bundled-plugin-runtime-deps.mjs && node --import tsx scripts/release-check.ts", + "release:check": "pnpm check:base-config-schema && pnpm check:bundled-channel-config-metadata && pnpm check:bundled-provider-auth-env-vars && pnpm config:docs:check && pnpm plugin-sdk:check-exports && pnpm plugin-sdk:facades:check && pnpm plugin-sdk:api:check && node --import tsx scripts/release-check.ts", "release:openclaw:npm:check": "node --import tsx scripts/openclaw-npm-release-check.ts", "release:openclaw:npm:verify-published": "node --import tsx scripts/openclaw-npm-postpublish-verify.ts", "release:plugins:npm:check": "node --import tsx scripts/plugin-npm-release-check.ts", diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 0f3c681dba9..a83c37b3ce4 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -58,6 +58,7 @@ const MAX_CALVER_DISTANCE_DAYS = 2; const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"]; const CONTROL_UI_ASSET_PREFIX = "dist/control-ui/assets/"; const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024; +const skipPackValidationEnv = "OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK"; function normalizeRepoUrl(value: unknown): string { if (typeof value !== "string") { @@ -99,6 +100,14 @@ export function resolveNpmDistTagMirrorAuth(params?: { }) as NpmDistTagMirrorAuth; } +export function shouldSkipPackedTarballValidation(env = process.env): boolean { + const raw = env[skipPackValidationEnv]; + if (!raw) { + return false; + } + return !/^(0|false)$/i.test(raw); +} + export function parseReleaseTagVersion(version: string): ParsedReleaseTag | null { const trimmed = version.trim(); if (!trimmed) { @@ -412,6 +421,7 @@ function collectPackedTarballErrors(): string[] { function main(): number { const pkg = loadPackageJson(); const now = new Date(); + const skipPackValidation = shouldSkipPackedTarballValidation(); const metadataErrors = collectReleasePackageMetadataErrors(pkg); const tagErrors = collectReleaseTagErrors({ packageVersion: pkg.version ?? "", @@ -420,7 +430,7 @@ function main(): number { releaseMainRef: process.env.RELEASE_MAIN_REF, now, }); - const tarballErrors = collectPackedTarballErrors(); + const tarballErrors = skipPackValidation ? [] : collectPackedTarballErrors(); const errors = [...metadataErrors, ...tagErrors, ...tarballErrors]; if (errors.length > 0) { @@ -435,7 +445,7 @@ function main(): number { const dayDistance = parsedVersion === null ? "unknown" : String(utcCalendarDayDistance(parsedVersion.date, now)); console.log( - `openclaw-npm-release-check: validated ${channel} release ${pkg.version} (${dayDistance} day UTC delta).`, + `openclaw-npm-release-check: validated ${channel} release ${pkg.version} (${dayDistance} day UTC delta${skipPackValidation ? "; metadata-only" : ""}).`, ); return 0; } diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index 045ffdc703b..8c96a5e08fa 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -10,6 +10,7 @@ import { resolveNpmDistTagMirrorAuth, resolveNpmPublishPlan, resolveNpmCommandInvocation, + shouldSkipPackedTarballValidation, utcCalendarDayDistance, } from "../scripts/openclaw-npm-release-check.ts"; @@ -155,6 +156,28 @@ describe("resolveNpmDistTagMirrorAuth", () => { }); }); +describe("shouldSkipPackedTarballValidation", () => { + it("defaults to full pack validation", () => { + expect(shouldSkipPackedTarballValidation({})).toBe(false); + }); + + it("accepts truthy values for metadata-only validation", () => { + expect( + shouldSkipPackedTarballValidation({ + OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "1", + }), + ).toBe(true); + }); + + it("treats false-like values as disabled", () => { + expect( + shouldSkipPackedTarballValidation({ + OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK: "false", + }), + ).toBe(false); + }); +}); + describe("compareReleaseVersions", () => { it("treats stable as newer than same-day beta", () => { expect(compareReleaseVersions("2026.3.29", "2026.3.29-beta.2")).toBe(1);