From ad06d5ab4db25d7d452a04b7091da02ede34387c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 1 Apr 2026 06:29:11 +0900 Subject: [PATCH] build: reuse release preflight artifacts --- .../openclaw-release-maintainer/SKILL.md | 32 +++-- .github/workflows/openclaw-npm-release.yml | 56 +++++++- docs/reference/RELEASING.md | 8 +- package.json | 4 +- scripts/openclaw-npm-publish.sh | 12 +- scripts/openclaw-prepack.ts | 126 ++++++++++++++++++ test/openclaw-prepack.test.ts | 34 +++++ 7 files changed, 254 insertions(+), 18 deletions(-) create mode 100644 scripts/openclaw-prepack.ts create mode 100644 test/openclaw-prepack.test.ts diff --git a/.agents/skills/openclaw-release-maintainer/SKILL.md b/.agents/skills/openclaw-release-maintainer/SKILL.md index 1b04340d777..22f4b95d059 100644 --- a/.agents/skills/openclaw-release-maintainer/SKILL.md +++ b/.agents/skills/openclaw-release-maintainer/SKILL.md @@ -64,7 +64,8 @@ Use this skill for release and publish-time workflow. Keep ordinary development Before tagging or publishing, run: ```bash -node --import tsx scripts/release-check.ts +pnpm build +pnpm ui:build pnpm release:check pnpm test:install:smoke ``` @@ -92,7 +93,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts - Default release checks: - `pnpm check` - `pnpm build` - - `node --import tsx scripts/release-check.ts` + - `pnpm ui:build` - `pnpm release:check` - `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` - Check all release-related build surfaces touched by the release, not only the npm package. @@ -119,6 +120,8 @@ 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. - 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. @@ -206,31 +209,38 @@ 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. + and wait for it to pass. Save that run id if you want the real publish 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-publish.yml` - with `preflight_only=true` and wait for it to pass. + 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, 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. -14. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`. -15. Start + 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. +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. -16. Verify the successful real private mac run uploaded the `.zip`, `.dmg`, +17. Verify the successful real private mac run uploaded the `.zip`, `.dmg`, and `.dSYM.zip` artifacts to the existing GitHub release in `openclaw/openclaw`. -17. For stable releases, download `macos-appcast-` from the successful +18. For stable releases, download `macos-appcast-` from the successful private mac run, update `appcast.xml` on `main`, and verify the feed. -18. For beta releases, publish the mac assets but expect no shared production +19. For beta releases, publish the mac assets but expect no shared production `appcast.xml` artifact and do not update the shared production feed unless a separate beta feed exists. -19. After publish, verify npm and the attached release artifacts. +20. After publish, verify npm and the attached release artifacts. ## GHSA advisory work diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index a22bbc0f6d4..06970dce713 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -12,6 +12,10 @@ on: required: true default: false type: boolean + preflight_run_id: + description: Existing preflight workflow run id to promote without rebuilding + required: false + type: string concurrency: group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.tag || github.ref }} @@ -97,6 +101,28 @@ jobs: - name: Verify release contents run: pnpm release:check + - name: Pack prepared npm tarball + id: packed_tarball + env: + OPENCLAW_PREPACK_PREPARED: "1" + run: | + set -euo pipefail + PACK_JSON="$(npm pack --json)" + echo "$PACK_JSON" + PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) { process.exit(1); } process.stdout.write(first.filename); });')" + if [[ -z "$PACK_PATH" || ! -f "$PACK_PATH" ]]; then + echo "npm pack did not produce a tarball file." >&2 + exit 1 + fi + echo "path=$PACK_PATH" >> "$GITHUB_OUTPUT" + + - name: Upload prepared npm tarball + uses: actions/upload-artifact@v7 + with: + name: openclaw-npm-preflight-${{ inputs.tag }} + path: ${{ steps.packed_tarball.outputs.path }} + if-no-files-found: error + validate_publish_dispatch_ref: if: ${{ !inputs.preflight_only }} runs-on: ubuntu-latest @@ -120,6 +146,7 @@ jobs: runs-on: ubuntu-latest environment: npm-release permissions: + actions: read contents: read id-token: write steps: @@ -159,10 +186,22 @@ jobs: echo "Publishing openclaw@${PACKAGE_VERSION}" + - name: Download prepared npm tarball + if: ${{ inputs.preflight_run_id != '' }} + uses: actions/download-artifact@v8 + with: + name: openclaw-npm-preflight-${{ inputs.tag }} + path: preflight-tarball + repository: ${{ github.repository }} + 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 @@ -178,5 +217,20 @@ 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 != '' }} + run: | + set -euo pipefail + TARBALL_PATH="$(find preflight-tarball -maxdepth 1 -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 + exit 1 + fi + echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT" + - name: Publish - run: bash scripts/openclaw-npm-publish.sh --publish + env: + OPENCLAW_PREPACK_PREPARED: "1" + run: bash scripts/openclaw-npm-publish.sh --publish "${{ steps.publish_tarball.outputs.path }}" diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 9e299712782..6e30f14b64b 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -37,8 +37,9 @@ OpenClaw has three public release lanes: ## Release preflight -- Run `pnpm build` before `pnpm release:check` so the expected `dist/*` release - artifacts exist for the pack validation step +- Run `pnpm build && pnpm ui:build` before `pnpm release:check` so the expected + `dist/*` release artifacts and Control UI bundle exist for the pack + validation step - Run `pnpm release:check` before every tagged release - Run `RELEASE_TAG=vYYYY.M.D node --import tsx scripts/openclaw-npm-release-check.ts` (or the matching beta/correction tag) before approval @@ -46,6 +47,9 @@ OpenClaw has three public release lanes: `node --import tsx scripts/openclaw-npm-postpublish-verify.ts YYYY.M.D` (or the matching beta/correction version) to verify the published registry install path in a fresh temp prefix +- Maintainer workflows may reuse a successful preflight run for the real + publish so the publish step promotes prepared release artifacts instead of + rebuilding them again - For stable correction releases like `YYYY.M.D-N`, the post-publish verifier also checks the same temp-prefix upgrade path from `YYYY.M.D` to `YYYY.M.D-N` so release corrections cannot silently leave older global installs on the diff --git a/package.json b/package.json index f8f5dcc5995..83e160832dd 100644 --- a/package.json +++ b/package.json @@ -1076,12 +1076,12 @@ "plugin-sdk:usage": "node --import tsx scripts/analyze-plugin-sdk-usage.ts", "plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts", "postinstall": "node scripts/postinstall-bundled-plugins.mjs", - "prepack": "pnpm build && pnpm ui:build", + "prepack": "node --import tsx scripts/openclaw-prepack.ts", "prepare": "command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1 && git config core.hooksPath git-hooks || exit 0", "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 && pnpm ui:build && 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 scripts/stage-bundled-plugin-runtime-deps.mjs && 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-publish.sh b/scripts/openclaw-npm-publish.sh index a96e1b27f8f..79a95fa437b 100644 --- a/scripts/openclaw-npm-publish.sh +++ b/scripts/openclaw-npm-publish.sh @@ -3,9 +3,10 @@ set -euo pipefail mode="${1:-}" +publish_target="${2:-}" if [[ "${mode}" != "--publish" ]]; then - echo "usage: bash scripts/openclaw-npm-publish.sh --publish" >&2 + echo "usage: bash scripts/openclaw-npm-publish.sh --publish [package.tgz]" >&2 exit 2 fi @@ -28,7 +29,11 @@ EOF release_channel="${publish_plan[0]}" publish_tag="${publish_plan[1]}" mirror_dist_tags_csv="${publish_plan[2]:-}" -publish_cmd=(npm publish --access public --tag "${publish_tag}" --provenance) +publish_cmd=(npm publish) +if [[ -n "${publish_target}" ]]; then + publish_cmd+=("${publish_target}") +fi +publish_cmd+=(--access public --tag "${publish_tag}" --provenance) echo "Resolved package version: ${package_version}" echo "Current beta dist-tag: ${current_beta_version:-}" @@ -36,6 +41,9 @@ echo "Resolved release channel: ${release_channel}" echo "Resolved publish tag: ${publish_tag}" echo "Resolved mirror dist-tags: ${mirror_dist_tags_csv:-}" echo "Publish auth: GitHub OIDC trusted publishing" +if [[ -n "${publish_target}" ]]; then + echo "Resolved publish target: ${publish_target}" +fi printf 'Publish command:' printf ' %q' "${publish_cmd[@]}" diff --git a/scripts/openclaw-prepack.ts b/scripts/openclaw-prepack.ts new file mode 100644 index 00000000000..7cedacea07b --- /dev/null +++ b/scripts/openclaw-prepack.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env -S node --import tsx + +import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; +import { pathToFileURL } from "node:url"; + +const skipPrepackPreparedEnv = "OPENCLAW_PREPACK_PREPARED"; +const requiredPreparedPathGroups = [ + ["dist/index.js", "dist/index.mjs"], + ["dist/control-ui/index.html"], +]; +const requiredControlUiAssetPrefix = "dist/control-ui/assets/"; + +type PreparedFileReader = { + existsSync: typeof existsSync; + readdirSync: typeof readdirSync; +}; + +function normalizeFiles(files: Iterable): Set { + return new Set(Array.from(files, (file) => file.replace(/\\/g, "/"))); +} + +export function shouldSkipPrepack(env = process.env): boolean { + const raw = env[skipPrepackPreparedEnv]; + if (!raw) { + return false; + } + return !/^(0|false)$/i.test(raw); +} + +export function collectPreparedPrepackErrors( + files: Iterable, + assetPaths: Iterable, +): string[] { + const normalizedFiles = normalizeFiles(files); + const normalizedAssets = normalizeFiles(assetPaths); + const errors: string[] = []; + + for (const group of requiredPreparedPathGroups) { + if (group.some((path) => normalizedFiles.has(path))) { + continue; + } + errors.push(`missing required prepared artifact: ${group.join(" or ")}`); + } + + if (!normalizedAssets.values().next().done) { + return errors; + } + + errors.push(`missing prepared Control UI asset payload under ${requiredControlUiAssetPrefix}`); + return errors; +} + +function collectPreparedFilePaths(reader: PreparedFileReader = { existsSync, readdirSync }): { + files: Set; + assets: string[]; +} { + const assets = reader + .readdirSync("dist/control-ui/assets", { withFileTypes: true }) + .flatMap((entry) => + entry.isDirectory() ? [] : [`${requiredControlUiAssetPrefix}${entry.name}`], + ); + + const files = new Set(); + for (const group of requiredPreparedPathGroups) { + for (const path of group) { + if (reader.existsSync(path)) { + files.add(path); + } + } + } + + return { + files, + assets, + }; +} + +function ensurePreparedArtifacts(): void { + try { + const preparedFiles = collectPreparedFilePaths(); + const errors = collectPreparedPrepackErrors(preparedFiles.files, preparedFiles.assets); + if (errors.length === 0) { + console.log( + `prepack: using prepared artifacts from ${skipPrepackPreparedEnv}; skipping rebuild.`, + ); + return; + } + for (const error of errors) { + console.error(`prepack: ${error}`); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.error(`prepack: failed to verify prepared artifacts: ${message}`); + } + + console.error( + `prepack: ${skipPrepackPreparedEnv}=1 requires an existing build and Control UI bundle. Run \`pnpm build && pnpm ui:build\` first or unset ${skipPrepackPreparedEnv}.`, + ); + process.exit(1); +} + +function run(command: string, args: string[]): void { + const result = spawnSync(command, args, { + stdio: "inherit", + env: process.env, + }); + if (result.status === 0) { + return; + } + process.exit(result.status ?? 1); +} + +function main(): void { + const pnpmCommand = process.platform === "win32" ? "pnpm.cmd" : "pnpm"; + if (shouldSkipPrepack()) { + ensurePreparedArtifacts(); + return; + } + run(pnpmCommand, ["build"]); + run(pnpmCommand, ["ui:build"]); +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + main(); +} diff --git a/test/openclaw-prepack.test.ts b/test/openclaw-prepack.test.ts new file mode 100644 index 00000000000..0db2dd75a7c --- /dev/null +++ b/test/openclaw-prepack.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { collectPreparedPrepackErrors, shouldSkipPrepack } from "../scripts/openclaw-prepack.ts"; + +describe("shouldSkipPrepack", () => { + it("treats unset and explicit false values as disabled", () => { + expect(shouldSkipPrepack({})).toBe(false); + expect(shouldSkipPrepack({ OPENCLAW_PREPACK_PREPARED: "0" })).toBe(false); + expect(shouldSkipPrepack({ OPENCLAW_PREPACK_PREPARED: "false" })).toBe(false); + }); + + it("treats non-false values as enabled", () => { + expect(shouldSkipPrepack({ OPENCLAW_PREPACK_PREPARED: "1" })).toBe(true); + expect(shouldSkipPrepack({ OPENCLAW_PREPACK_PREPARED: "true" })).toBe(true); + }); +}); + +describe("collectPreparedPrepackErrors", () => { + it("accepts prepared release artifacts", () => { + expect( + collectPreparedPrepackErrors( + ["dist/index.mjs", "dist/control-ui/index.html"], + ["dist/control-ui/assets/index-Bu8rSoJV.js"], + ), + ).toEqual([]); + }); + + it("reports missing build and control ui artifacts", () => { + expect(collectPreparedPrepackErrors([], [])).toEqual([ + "missing required prepared artifact: dist/index.js or dist/index.mjs", + "missing required prepared artifact: dist/control-ui/index.html", + "missing prepared Control UI asset payload under dist/control-ui/assets/", + ]); + }); +});