mirror of https://github.com/openclaw/openclaw.git
build: reuse release preflight artifacts
This commit is contained in:
parent
6679690737
commit
ad06d5ab4d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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[@]}"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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/",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue