build: reuse release preflight artifacts

This commit is contained in:
Peter Steinberger 2026-04-01 06:29:11 +09:00
parent 6679690737
commit ad06d5ab4d
No known key found for this signature in database
7 changed files with 254 additions and 18 deletions

View File

@ -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 <published-version>
- 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 <published-version>
- 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 <published-version>
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-<tag>` from the successful
18. For stable releases, download `macos-appcast-<tag>` 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

View File

@ -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 }}"

View File

@ -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

View File

@ -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",

View File

@ -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:-<missing>}"
@ -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:-<none>}"
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[@]}"

126
scripts/openclaw-prepack.ts Normal file
View File

@ -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<string>): Set<string> {
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<string>,
assetPaths: Iterable<string>,
): 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<string>;
assets: string[];
} {
const assets = reader
.readdirSync("dist/control-ui/assets", { withFileTypes: true })
.flatMap((entry) =>
entry.isDirectory() ? [] : [`${requiredControlUiAssetPrefix}${entry.name}`],
);
const files = new Set<string>();
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();
}

View File

@ -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/",
]);
});
});