* 'main' of https://github.com/openclaw/openclaw:
  Plugins: reserve context engine ownership (#47595)
  fix(release): block oversized npm packs that regress low-memory startup (#46850)
  Scripts: rebuild on extension and tsdown config changes (#47571)
  Docs: move release runbook to maintainer repo (#47532)
  docs(zalo): document current Marketplace bot behavior (openclaw#47552)
This commit is contained in:
Vincent Koc 2026-03-15 13:42:21 -07:00
commit b795ba1d02
26 changed files with 1098 additions and 564 deletions

View File

@ -72,6 +72,8 @@
- `docs/zh-CN/**` is generated; do not edit unless the user explicitly asks.
- Pipeline: update English docs → adjust glossary (`docs/.i18n/glossary.zh-CN.json`) → run `scripts/docs-i18n` → apply targeted fixes only if instructed.
- Before rerunning `scripts/docs-i18n`, add glossary entries for any new technical terms, page titles, or short nav labels that must stay in English or use a fixed translation (for example `Doctor` or `Polls`).
- `pnpm docs:check-i18n-glossary` enforces glossary coverage for changed English doc titles and short internal doc labels before translation reruns.
- Translation memory: `docs/.i18n/zh-CN.tm.jsonl` (generated).
- See `docs/.i18n/README.md`.
- The pipeline can be slow/inefficient; if its dragging, ping @jospalmbier on Discord instead of hacking around it.
@ -97,7 +99,7 @@
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm openclaw ...` (bun) or `pnpm dev`.
- Node remains supported for running built output (`dist/*`) and production installs.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch. Release checklist: `docs/platforms/mac/release.md`.
- Mac packaging (dev): `scripts/package-mac-app.sh` defaults to current arch.
- Type-check/build: `pnpm build`
- TypeScript checks: `pnpm tsgo`
- Lint/format: `pnpm check`
@ -179,7 +181,7 @@
- Pi sessions live under `~/.openclaw/sessions/` by default; the base directory is not configurable.
- Environment variables: see `~/.profile`.
- Never commit or publish real phone numbers, videos, or live configuration values. Use obviously fake placeholders in docs, tests, and examples.
- Release flow: always read `docs/reference/RELEASING.md` and `docs/platforms/mac/release.md` before any release work; do not ask routine questions once those docs answer them.
- Release flow: use the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md) for the actual runbook; use `docs/reference/RELEASING.md` for the public release policy.
## GHSA (Repo Advisory) Patch/Publish
@ -256,14 +258,13 @@
- If shared guardrails are available locally, review them; otherwise follow this repo's guidance.
- SwiftUI state management (iOS/macOS): prefer the `Observation` framework (`@Observable`, `@Bindable`) over `ObservableObject`/`@StateObject`; dont introduce new `ObservableObject` unless required for compatibility, and migrate existing usages when touching related code.
- Connection providers: when adding a new connection, update every UI surface and docs (macOS app, web UI, mobile if applicable, onboarding/overview docs) and add matching status + configuration forms so provider lists and settings stay in sync.
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), `docs/platforms/mac/release.md` (APP_VERSION/APP_BUILD examples), Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- Version locations: `package.json` (CLI), `apps/android/app/build.gradle.kts` (versionName/versionCode), `apps/ios/Sources/Info.plist` + `apps/ios/Tests/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `apps/macos/Sources/OpenClaw/Resources/Info.plist` (CFBundleShortVersionString/CFBundleVersion), `docs/install/updating.md` (pinned npm version), and Peekaboo Xcode projects/Info.plists (MARKETING_VERSION/CURRENT_PROJECT_VERSION).
- "Bump version everywhere" means all version locations above **except** `appcast.xml` (only touch appcast when cutting a new macOS Sparkle release).
- **Restart apps:** “restart iOS/Android apps” means rebuild (recompile/install) and relaunch, not just kill/launch.
- **Device checks:** before testing, verify connected real devices (iOS/Android) before reaching for simulators/emulators.
- iOS Team ID lookup: `security find-identity -p codesigning -v` → use Apple Development (…) TEAMID. Fallback: `defaults read com.apple.dt.Xcode IDEProvisioningTeamIdentifiers`.
- A2UI bundle hash: `src/canvas-host/a2ui/.bundle.hash` is auto-generated; ignore unexpected changes, and only regenerate via `pnpm canvas:a2ui:bundle` (or `scripts/bundle-a2ui.sh`) when needed. Commit the hash as a separate commit.
- Release signing/notary keys are managed outside the repo; follow internal release docs.
- Notary auth env vars (`APP_STORE_CONNECT_ISSUER_ID`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_API_KEY_P8`) are expected in your environment (per internal release docs).
- Release signing/notary credentials are managed outside the repo; maintainers keep that setup in the private [maintainer release docs](https://github.com/openclaw/maintainers/tree/main/release).
- **Multi-agent safety:** do **not** create/apply/drop `git stash` entries unless explicitly requested (this includes `git pull --rebase --autostash`). Assume other agents may be working; keep unrelated WIP untouched and avoid cross-cutting state changes.
- **Multi-agent safety:** when the user says "push", you may `git pull --rebase` to integrate latest changes (never discard other agents' work). When the user says "commit", scope to your changes only. When the user says "commit all", commit everything in grouped chunks.
- **Multi-agent safety:** do **not** create/remove/modify `git worktree` checkouts (or edit `.worktrees/*`) unless explicitly requested.
@ -290,35 +291,12 @@
- Release guardrails: do not change version numbers without operators explicit consent; always ask permission before running any npm publish/release step.
- Beta release guardrail: when using a beta Git tag (for example `vYYYY.M.D-beta.N`), publish npm with a matching beta version suffix (for example `YYYY.M.D-beta.N`) rather than a plain version on `--tag beta`; otherwise the plain version name gets consumed/blocked.
## NPM + 1Password (publish/verify)
## Release Auth
- Use the 1password skill; all `op` commands must run inside a fresh tmux session.
- Correct 1Password path for npm release auth: `op://Private/Npmjs` (use that item; OTP stays `op://Private/Npmjs/one-time password?attribute=otp`).
- Sign in: `eval "$(op signin --account my.1password.com)"` (app unlocked + integration on).
- OTP: `op read 'op://Private/Npmjs/one-time password?attribute=otp'`.
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
- Kill the tmux session after publish.
## Plugin Release Fast Path (no core `openclaw` publish)
- Release only already-on-npm plugins. Source list is in `docs/reference/RELEASING.md` under "Current npm plugin list".
- Run all CLI `op` calls and `npm publish` inside tmux to avoid hangs/interruption:
- `tmux new -d -s release-plugins-$(date +%Y%m%d-%H%M%S)`
- `eval "$(op signin --account my.1password.com)"`
- 1Password helpers:
- password used by `npm login`:
`op item get Npmjs --format=json | jq -r '.fields[] | select(.id=="password").value'`
- OTP:
`op read 'op://Private/Npmjs/one-time password?attribute=otp'`
- Fast publish loop (local helper script in `/tmp` is fine; keep repo clean):
- compare local plugin `version` to `npm view <name> version`
- only run `npm publish --access public --otp="<otp>"` when versions differ
- skip if package is missing on npm or version already matches.
- Keep `openclaw` untouched: never run publish from repo root unless explicitly requested.
- Post-check for each release:
- per-plugin: `npm view @openclaw/<name> version --userconfig "$(mktemp)"` should be `2026.2.17`
- core guard: `npm view openclaw version --userconfig "$(mktemp)"` should stay at previous version unless explicitly requested.
- Core `openclaw` publish uses GitHub trusted publishing; do not use `NPM_TOKEN` or the plugin OTP flow for core releases.
- Separate `@openclaw/*` plugin publishes use a different maintainer-only auth flow.
- Plugin scope: only publish already-on-npm `@openclaw/*` plugins. Bundled disk-tree-only plugins stay out.
- Maintainers: private 1Password item names, tmux rules, plugin publish helpers, and local mac signing/notary setup live in the private [maintainer release docs](https://github.com/openclaw/maintainers/blob/main/release/README.md).
## Changelog Release Notes

View File

@ -13,6 +13,7 @@ Docs: https://docs.openclaw.ai
- Feishu/streaming: add `onReasoningStream` and `onReasoningEnd` support to streaming cards, so `/reasoning stream` renders thinking tokens as markdown blockquotes in the same card — matching the Telegram channel's reasoning lane behavior. (#46029)
- Refactor/channels: remove the legacy channel shim directories and point channel-specific imports directly at the extension-owned implementations. (#45967) thanks @scoootscooob.
- Android/nodes: add `callLog.search` plus shared Call Log permission wiring so Android nodes can search recent call history through the gateway. (#44073) Thanks @lxk7280.
- Docs/Zalo: clarify the Marketplace-bot support matrix and config guidance so the Zalo channel docs match current Bot Creator behavior more closely. (#47552) Thanks @No898.
### Fixes
@ -64,6 +65,7 @@ Docs: https://docs.openclaw.ai
- CLI/auth choice: lazy-load plugin/provider fallback resolution so mapped auth choices stay on the static path and only unknown choices pay the heavy provider load. (#47495) Thanks @vincentkoc.
- CLI/completion: reduce recursive completion-script string churn and fix nested PowerShell command-path matching so generated nested completions resolve on PowerShell too. (#45537) Thanks @yiShanXin and @vincentkoc.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse.
- Gateway/watch mode: restart on bundled-plugin package and manifest metadata changes, rebuild `dist` for extension source and `tsdown.config.ts` changes, and still ignore extension docs. (#47571) thanks @gumadeiras.
## 2026.3.13

View File

@ -103,7 +103,7 @@ pnpm build
pnpm openclaw onboard --install-daemon
# Dev loop (auto-reload on TS changes)
# Dev loop (auto-reload on source/config changes)
pnpm gateway:watch
```

View File

@ -123,6 +123,22 @@
"source": "Network model",
"target": "网络模型"
},
{
"source": "Doctor",
"target": "Doctor"
},
{
"source": "Polls",
"target": "投票"
},
{
"source": "Release Policy",
"target": "发布策略"
},
{
"source": "Release policy",
"target": "发布策略"
},
{
"source": "for full details",
"target": "了解详情"

View File

@ -7,7 +7,7 @@ title: "Zalo"
# Zalo (Bot API)
Status: experimental. DMs are supported; group handling is available with explicit group policy controls.
Status: experimental. DMs are supported. The [Capabilities](#capabilities) section below reflects current Marketplace-bot behavior.
## Plugin required
@ -25,7 +25,7 @@ Zalo ships as a plugin and is not bundled with the core install.
- Or pick **Zalo** in onboarding and confirm the install prompt
2. Set the token:
- Env: `ZALO_BOT_TOKEN=...`
- Or config: `channels.zalo.botToken: "..."`.
- Or config: `channels.zalo.accounts.default.botToken: "..."`.
3. Restart the gateway (or finish onboarding).
4. DM access is pairing by default; approve the pairing code on first contact.
@ -36,8 +36,12 @@ Minimal config:
channels: {
zalo: {
enabled: true,
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
accounts: {
default: {
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
},
},
},
},
}
@ -48,10 +52,13 @@ Minimal config:
Zalo is a Vietnam-focused messaging app; its Bot API lets the Gateway run a bot for 1:1 conversations.
It is a good fit for support or notifications where you want deterministic routing back to Zalo.
This page reflects current OpenClaw behavior for **Zalo Bot Creator / Marketplace bots**.
**Zalo Official Account (OA) bots** are a different Zalo product surface and may behave differently.
- A Zalo Bot API channel owned by the Gateway.
- Deterministic routing: replies go back to Zalo; the model never chooses channels.
- DMs share the agent's main session.
- Groups are supported with policy controls (`groupPolicy` + `groupAllowFrom`) and default to fail-closed allowlist behavior.
- The [Capabilities](#capabilities) section below shows current Marketplace-bot support.
## Setup (fast path)
@ -59,7 +66,7 @@ It is a good fit for support or notifications where you want deterministic routi
1. Go to [https://bot.zaloplatforms.com](https://bot.zaloplatforms.com) and sign in.
2. Create a new bot and configure its settings.
3. Copy the bot token (format: `12345689:abc-xyz`).
3. Copy the full bot token (typically `numeric_id:secret`). For Marketplace bots, the usable runtime token may appear in the bot's welcome message after creation.
### 2) Configure the token (env or config)
@ -70,13 +77,19 @@ Example:
channels: {
zalo: {
enabled: true,
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
accounts: {
default: {
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
},
},
},
},
}
```
If you later move to a Zalo bot surface where groups are available, you can add group-specific config such as `groupPolicy` and `groupAllowFrom` explicitly. For current Marketplace-bot behavior, see [Capabilities](#capabilities).
Env option: `ZALO_BOT_TOKEN=...` (works for the default account only).
Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`.
@ -109,14 +122,23 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Access control (Groups)
For **Zalo Bot Creator / Marketplace bots**, group support was not available in practice because the bot could not be added to a group at all.
That means the group-related config keys below exist in the schema, but were not usable for Marketplace bots:
- `channels.zalo.groupPolicy` controls group inbound handling: `open | allowlist | disabled`.
- Default behavior is fail-closed: `allowlist`.
- `channels.zalo.groupAllowFrom` restricts which sender IDs can trigger the bot in groups.
- If `groupAllowFrom` is unset, Zalo falls back to `allowFrom` for sender checks.
- `groupPolicy: "disabled"` blocks all group messages.
- `groupPolicy: "open"` allows any group member (mention-gated).
- Runtime note: if `channels.zalo` is missing entirely, runtime still falls back to `groupPolicy="allowlist"` for safety.
The group policy values (when group access is available on your bot surface) are:
- `groupPolicy: "disabled"` — blocks all group messages.
- `groupPolicy: "open"` — allows any group member (mention-gated).
- `groupPolicy: "allowlist"` — fail-closed default; only allowed senders are accepted.
If you are using a different Zalo bot product surface and have verified working group behavior, document that separately rather than assuming it matches the Marketplace-bot flow.
## Long-polling vs webhook
- Default: long-polling (no public URL required).
@ -133,23 +155,36 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
## Supported message types
For a quick support snapshot, see [Capabilities](#capabilities). The notes below add detail where the behavior needs extra context.
- **Text messages**: Full support with 2000 character chunking.
- **Image messages**: Download and process inbound images; send images via `sendPhoto`.
- **Stickers**: Logged but not fully processed (no agent response).
- **Unsupported types**: Logged (e.g., messages from protected users).
- **Plain URLs in text**: Behave like normal text input.
- **Link previews / rich link cards**: See the Marketplace-bot status in [Capabilities](#capabilities); they did not reliably trigger a reply.
- **Image messages**: See the Marketplace-bot status in [Capabilities](#capabilities); inbound image handling was unreliable (typing indicator without a final reply).
- **Stickers**: See the Marketplace-bot status in [Capabilities](#capabilities).
- **Voice notes / audio files / video / generic file attachments**: See the Marketplace-bot status in [Capabilities](#capabilities).
- **Unsupported types**: Logged (for example, messages from protected users).
## Capabilities
| Feature | Status |
| --------------- | -------------------------------------------------------- |
| Direct messages | ✅ Supported |
| Groups | ⚠️ Supported with policy controls (allowlist by default) |
| Media (images) | ✅ Supported |
| Reactions | ❌ Not supported |
| Threads | ❌ Not supported |
| Polls | ❌ Not supported |
| Native commands | ❌ Not supported |
| Streaming | ⚠️ Blocked (2000 char limit) |
This table summarizes current **Zalo Bot Creator / Marketplace bot** behavior in OpenClaw.
| Feature | Status |
| --------------------------- | --------------------------------------- |
| Direct messages | ✅ Supported |
| Groups | ❌ Not available for Marketplace bots |
| Media (inbound images) | ⚠️ Limited / verify in your environment |
| Media (outbound images) | ⚠️ Not re-tested for Marketplace bots |
| Plain URLs in text | ✅ Supported |
| Link previews | ⚠️ Unreliable for Marketplace bots |
| Reactions | ❌ Not supported |
| Stickers | ⚠️ No agent reply for Marketplace bots |
| Voice notes / audio / video | ⚠️ No agent reply for Marketplace bots |
| File attachments | ⚠️ No agent reply for Marketplace bots |
| Threads | ❌ Not supported |
| Polls | ❌ Not supported |
| Native commands | ❌ Not supported |
| Streaming | ⚠️ Blocked (2000 char limit) |
## Delivery targets (CLI/cron)
@ -175,6 +210,8 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
Full configuration: [Configuration](/gateway/configuration)
The flat top-level keys (`channels.zalo.botToken`, `channels.zalo.dmPolicy`, and similar) are a legacy single-account shorthand. Prefer `channels.zalo.accounts.<id>.*` for new configs. Both forms are still documented here because they exist in the schema.
Provider options:
- `channels.zalo.enabled`: enable/disable channel startup.
@ -182,7 +219,7 @@ Provider options:
- `channels.zalo.tokenFile`: read token from a regular file path. Symlinks are rejected.
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
- `channels.zalo.groupPolicy`: `open | allowlist | disabled` (default: allowlist). Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior.
- `channels.zalo.groupAllowFrom`: group sender allowlist (user IDs). Falls back to `allowFrom` when unset.
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
@ -198,7 +235,7 @@ Multi-account options:
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy.
- `channels.zalo.accounts.<id>.groupPolicy`: per-account group policy. Present in config; see [Capabilities](#capabilities) and [Access control (Groups)](#access-control-groups) for current Marketplace-bot behavior.
- `channels.zalo.accounts.<id>.groupAllowFrom`: per-account group sender allowlist.
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.

View File

@ -469,7 +469,7 @@
},
{
"source": "/mac/release",
"destination": "/platforms/mac/release"
"destination": "/reference/RELEASING"
},
{
"source": "/mac/remote",
@ -1166,7 +1166,6 @@
"platforms/mac/permissions",
"platforms/mac/remote",
"platforms/mac/signing",
"platforms/mac/release",
"platforms/mac/bundled-gateway",
"platforms/mac/xpc",
"platforms/mac/skills",
@ -1351,7 +1350,7 @@
"pages": ["reference/credits"]
},
{
"group": "Release notes",
"group": "Release policy",
"pages": ["reference/RELEASING", "reference/test"]
},
{
@ -1750,7 +1749,6 @@
"zh-CN/platforms/mac/permissions",
"zh-CN/platforms/mac/remote",
"zh-CN/platforms/mac/signing",
"zh-CN/platforms/mac/release",
"zh-CN/platforms/mac/bundled-gateway",
"zh-CN/platforms/mac/xpc",
"zh-CN/platforms/mac/skills",
@ -1933,7 +1931,7 @@
"pages": ["zh-CN/reference/credits"]
},
{
"group": "发布说明",
"group": "发布策略",
"pages": ["zh-CN/reference/RELEASING", "zh-CN/reference/test"]
},
{

View File

@ -40,11 +40,17 @@ pnpm gateway:watch
This maps to:
```bash
node --watch-path src --watch-path tsconfig.json --watch-path package.json --watch-preserve-output scripts/run-node.mjs gateway --force
node scripts/watch-node.mjs gateway --force
```
Add any gateway CLI flags after `gateway:watch` and they will be passed through
on each restart.
The watcher restarts on build-relevant files under `src/`, extension source files,
extension `package.json` and `openclaw.plugin.json` metadata, `tsconfig.json`,
`package.json`, and `tsdown.config.ts`. Extension metadata changes restart the
gateway without forcing a `tsdown` rebuild; source and config changes still
rebuild `dist` first.
Add any gateway CLI flags after `gateway:watch` and they will be passed through on
each restart.
## Dev profile + dev gateway (--dev)

View File

@ -1,90 +0,0 @@
---
summary: "OpenClaw macOS release checklist (Sparkle feed, packaging, signing)"
read_when:
- Cutting or validating a OpenClaw macOS release
- Updating the Sparkle appcast or feed assets
title: "macOS Release"
---
# OpenClaw macOS release (Sparkle)
This app now ships Sparkle auto-updates. Release builds must be Developer IDsigned, zipped, and published with a signed appcast entry.
## Prereqs
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
- We use a Keychain profile named `openclaw-notary`, created from App Store Connect API key env vars in your shell profile:
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
- `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8`
- `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"`
- `pnpm` deps installed (`pnpm install --config.node-linker=hoisted`).
- Sparkle tools are fetched automatically via SwiftPM at `apps/macos/.build/artifacts/sparkle/Sparkle/bin/` (`sign_update`, `generate_appcast`, etc.).
## Build & package
Notes:
- `APP_BUILD` maps to `CFBundleVersion`/`sparkle:version`; keep it numeric + monotonic (no `-beta`), or Sparkle compares it as equal.
- If `APP_BUILD` is omitted, `scripts/package-mac-app.sh` derives a Sparkle-safe default from `APP_VERSION` (`YYYYMMDDNN`: stable defaults to `90`, prereleases use a suffix-derived lane) and uses the higher of that value and git commit count.
- You can still override `APP_BUILD` explicitly when release engineering needs a specific monotonic value.
- For `BUILD_CONFIG=release`, `scripts/package-mac-app.sh` now defaults to universal (`arm64 x86_64`) automatically. You can still override with `BUILD_ARCHS=arm64` or `BUILD_ARCHS=x86_64`. For local/dev builds (`BUILD_CONFIG=debug`), it defaults to the current architecture (`$(uname -m)`).
- Use `scripts/package-mac-dist.sh` for release artifacts (zip + DMG + notarization). Use `scripts/package-mac-app.sh` for local/dev packaging.
```bash
# From repo root; set release IDs so Sparkle feed is enabled.
# This command builds release artifacts without notarization.
# APP_BUILD must be numeric + monotonic for Sparkle compare.
# Default is auto-derived from APP_VERSION when omitted.
SKIP_NOTARIZE=1 \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.13 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# `package-mac-dist.sh` already creates the zip + DMG.
# If you used `package-mac-app.sh` directly instead, create them manually:
# If you want notarization/stapling in this step, use the NOTARIZE command below.
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.3.13.zip
# Optional: build a styled DMG for humans (drag to /Applications)
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.3.13.dmg
# Recommended: build + notarize/staple zip + DMG
# First, create a keychain profile once:
# xcrun notarytool store-credentials "openclaw-notary" \
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=ai.openclaw.mac \
APP_VERSION=2026.3.13 \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# Optional: ship dSYM alongside the release
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.3.13.dSYM.zip
```
## Appcast entry
Use the release note generator so Sparkle renders formatted HTML notes:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.3.13.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
Generates HTML release notes from `CHANGELOG.md` (via [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh)) and embeds them in the appcast entry.
Commit the updated `appcast.xml` alongside the release assets (zip + dSYM) when publishing.
## Publish & verify
- Upload `OpenClaw-2026.3.13.zip` (and `OpenClaw-2026.3.13.dSYM.zip`) to the GitHub release for tag `v2026.3.13`.
- Ensure the raw appcast URL matches the baked feed: `https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`.
- Sanity checks:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` returns 200.
- `curl -I <enclosure url>` returns 200 after assets upload.
- On a previous public build, run “Check for Updates…” from the About tab and verify Sparkle installs the new build cleanly.
Definition of done: signed app + appcast are published, update flow works from an older installed version, and release assets are attached to the GitHub release.

View File

@ -1,161 +1,42 @@
---
title: "Release Checklist"
summary: "Step-by-step release checklist for npm + macOS app"
title: "Release Policy"
summary: "Public release channels, version naming, and cadence"
read_when:
- Cutting a new npm release
- Cutting a new macOS app release
- Verifying metadata before publishing
- Looking for public release channel definitions
- Looking for version naming and cadence
---
# Release Checklist (npm + macOS)
# Release Policy
Use `pnpm` from the repo root with Node 24 by default. Node 22 LTS, currently `22.16+`, remains supported for compatibility. Keep the working tree clean before tagging/publishing.
OpenClaw has three public release lanes:
## Operator trigger
- stable: tagged releases that publish to npm `latest`
- beta: prerelease tags that publish to npm `beta`
- dev: the moving head of `main`
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
## Versioning
Current OpenClaw releases use date-based versioning.
## Version naming
- Stable release version: `YYYY.M.D`
- Git tag: `vYYYY.M.D`
- Examples from repo history: `v2026.2.26`, `v2026.3.8`
- Beta prerelease version: `YYYY.M.D-beta.N`
- Git tag: `vYYYY.M.D-beta.N`
- Examples from repo history: `v2026.2.15-beta.1`, `v2026.3.8-beta.1`
- Fallback correction tag: `vYYYY.M.D-N`
- Use only as a last-resort recovery tag when a published immutable release burned the original stable tag and you cannot reuse it.
- The npm package version stays `YYYY.M.D`; the `-N` suffix is only for the git tag and GitHub release.
- Prefer betas for normal pre-release iteration, then cut a clean stable tag once ready.
- Use the same version string everywhere, minus the leading `v` where Git tags are not used:
- `package.json`: `2026.3.8`
- Git tag: `v2026.3.8`
- GitHub release title: `openclaw 2026.3.8`
- Do not zero-pad month or day. Use `2026.3.8`, not `2026.03.08`.
- Stable and beta are npm dist-tags, not separate release lines:
- `latest` = stable
- `beta` = prerelease/testing
- Dev is the moving head of `main`, not a normal git-tagged release.
- The tag-triggered preview run accepts stable, beta, and fallback correction tags, and rejects versions whose CalVer date is more than 2 UTC calendar days away from the release date.
- Do not zero-pad month or day
- `latest` means the current stable npm release
- `beta` means the current prerelease npm release
- Beta releases may ship before the macOS app catches up
Historical note:
## Release cadence
- Older tags such as `v2026.1.11-1`, `v2026.2.6-3`, and `v2.0.0-beta2` exist in repo history.
- Treat correction tags as a fallback-only escape hatch. New releases should still use `vYYYY.M.D` for stable and `vYYYY.M.D-beta.N` for beta.
- Releases move beta-first
- Stable follows only after the latest beta is validated
- Detailed release procedure, approvals, credentials, and recovery notes are
maintainer-only
1. **Version & metadata**
## Public references
- [ ] Bump `package.json` version (e.g., `2026.1.29`).
- [ ] Run `pnpm plugins:sync` to align extension package versions + changelogs.
- [ ] Update CLI/version strings in [`src/version.ts`](https://github.com/openclaw/openclaw/blob/main/src/version.ts) and the Baileys user agent in [`src/web/session.ts`](https://github.com/openclaw/openclaw/blob/main/src/web/session.ts).
- [ ] Confirm package metadata (name, description, repository, keywords, license) and `bin` map points to [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) for `openclaw`.
- [ ] If dependencies changed, run `pnpm install` so `pnpm-lock.yaml` is current.
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
2. **Build & artifacts**
- [ ] If A2UI inputs changed, run `pnpm canvas:a2ui:bundle` and commit any updated [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js).
- [ ] `pnpm run build` (regenerates `dist/`).
- [ ] Verify npm package `files` includes all required `dist/*` folders (notably `dist/node-host/**` and `dist/acp/**` for headless node + ACP CLI).
- [ ] Confirm `dist/build-info.json` exists and includes the expected `commit` hash (CLI banner uses this for npm installs).
- [ ] Optional: `npm pack --pack-destination /tmp` after the build; inspect the tarball contents and keep it handy for the GitHub release (do **not** commit it).
3. **Changelog & docs**
- [ ] Update `CHANGELOG.md` with user-facing highlights (create the file if missing); keep entries strictly descending by version.
- [ ] Ensure README examples/flags match current CLI behavior (notably new commands or options).
4. **Validation**
- [ ] `pnpm build`
- [ ] `pnpm check`
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
- [ ] `pnpm release:check` (verifies npm pack contents)
- [ ] If `pnpm config:docs:check` fails as part of release validation and the config-surface change is intentional, run `pnpm config:docs:gen`, review `docs/.generated/config-baseline.json` and `docs/.generated/config-baseline.jsonl`, commit the updated baselines, then rerun `pnpm release:check`.
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
- If the immediate previous npm release is known broken, set `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://openclaw.ai/install.sh | bash`, onboards, then runs real tool calls):
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
- `pnpm test:install:e2e` (requires both keys; runs both providers)
- [ ] (Optional) Spot-check the web gateway if your changes affect send/receive paths.
5. **macOS app (Sparkle)**
- [ ] Build + sign the macOS app, then zip it for distribution.
- [ ] Generate the Sparkle appcast (HTML notes via [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh)) and update `appcast.xml`.
- [ ] Keep the app zip (and optional dSYM zip) ready to attach to the GitHub release.
- [ ] Follow [macOS release](/platforms/mac/release) for the exact commands and required env vars.
- `APP_BUILD` must be numeric + monotonic (no `-beta`) so Sparkle compares versions correctly.
- If notarizing, use the `openclaw-notary` keychain profile created from App Store Connect API env vars (see [macOS release](/platforms/mac/release)).
6. **Publish (npm)**
- [ ] Confirm git status is clean; commit and push as needed.
- [ ] Confirm npm trusted publishing is configured for the `openclaw` package.
- [ ] Do not rely on an `NPM_TOKEN` secret for this workflow; the publish job uses GitHub OIDC trusted publishing.
- [ ] Push the matching git tag to trigger the preview run in `.github/workflows/openclaw-npm-release.yml`.
- [ ] Run `OpenClaw NPM Release` manually with the same tag to publish after `npm-release` environment approval.
- Stable tags publish to npm `latest`.
- Beta tags publish to npm `beta`.
- Fallback correction tags like `v2026.3.13-1` map to npm version `2026.3.13`.
- Both the preview run and the manual publish run reject tags that do not map back to `package.json`, are not on `main`, or whose CalVer date is more than 2 UTC calendar days away from the release date.
- If `openclaw@YYYY.M.D` is already published, a fallback correction tag is still useful for GitHub release and Docker recovery, but npm publish will not republish that version.
- [ ] Verify the registry: `npm view openclaw version`, `npm view openclaw dist-tags`, and `npx -y openclaw@X.Y.Z --version` (or `--help`).
### Troubleshooting (notes from 2.0.0-beta2 release)
- **npm pack/publish hangs or produces huge tarball**: the macOS app bundle in `dist/OpenClaw.app` (and release zips) get swept into the package. Fix by whitelisting publish contents via `package.json` `files` (include dist subdirs, docs, skills; exclude app bundles). Confirm with `npm pack --dry-run` that `dist/OpenClaw.app` is not listed.
- **npm auth web loop for dist-tags**: use legacy auth to get an OTP prompt:
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
- **`npx` verification fails with `ECOMPROMISED: Lock compromised`**: retry with a fresh cache:
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
- **Tag needs recovery after a late fix**: if the original stable tag is tied to an immutable GitHub release, mint a fallback correction tag like `vX.Y.Z-1` instead of trying to force-update `vX.Y.Z`.
- Keep the npm package version at `X.Y.Z`; the correction suffix is for the git tag and GitHub release only.
- Use this only as a last resort. For normal iteration, prefer beta tags and then cut a clean stable release.
7. **GitHub release + appcast**
- [ ] Tag and push: `git tag vX.Y.Z && git push origin vX.Y.Z` (or `git push --tags`).
- Pushing the tag also triggers the npm release workflow.
- [ ] Create/refresh the GitHub release for `vX.Y.Z` with **title `openclaw X.Y.Z`** (not just the tag); body should include the **full** changelog section for that version (Highlights + Changes + Fixes), inline (no bare links), and **must not repeat the title inside the body**.
- [ ] Attach artifacts: `npm pack` tarball (optional), `OpenClaw-X.Y.Z.zip`, and `OpenClaw-X.Y.Z.dSYM.zip` (if generated).
- [ ] Commit the updated `appcast.xml` and push it (Sparkle feeds from main).
- [ ] From a clean temp directory (no `package.json`), run `npx -y openclaw@X.Y.Z send --help` to confirm install/CLI entrypoints work.
- [ ] Announce/share release notes.
## Plugin publish scope (npm)
We only publish **existing npm plugins** under the `@openclaw/*` scope. Bundled
plugins that are not on npm stay **disk-tree only** (still shipped in
`extensions/**`).
Process to derive the list:
1. `npm search @openclaw --json` and capture the package names.
2. Compare with `extensions/*/package.json` names.
3. Publish only the **intersection** (already on npm).
Current npm plugin list (update as needed):
- @openclaw/bluebubbles
- @openclaw/diagnostics-otel
- @openclaw/discord
- @openclaw/feishu
- @openclaw/lobster
- @openclaw/matrix
- @openclaw/msteams
- @openclaw/nextcloud-talk
- @openclaw/nostr
- @openclaw/voice-call
- @openclaw/zalo
- @openclaw/zalouser
Release notes must also call out **new optional bundled plugins** that are **not
on by default** (example: `tlon`).
Maintainers use the private release docs in
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
for the actual runbook.

View File

@ -157,7 +157,6 @@ Use these hubs to discover every page, including deep dives and reference docs t
- [macOS permissions](/platforms/mac/permissions)
- [macOS remote](/platforms/mac/remote)
- [macOS signing](/platforms/mac/signing)
- [macOS release](/platforms/mac/release)
- [macOS gateway (launchd)](/platforms/mac/bundled-gateway)
- [macOS XPC](/platforms/mac/xpc)
- [macOS skills](/platforms/mac/skills)
@ -190,5 +189,5 @@ Use these hubs to discover every page, including deep dives and reference docs t
## Testing + release
- [Testing](/reference/test)
- [Release checklist](/reference/RELEASING)
- [Release policy](/reference/RELEASING)
- [Device models](/reference/device-models)

View File

@ -96,7 +96,8 @@ pnpm install
pnpm gateway:watch
```
`gateway:watch` runs the gateway in watch mode and reloads on TypeScript changes.
`gateway:watch` runs the gateway in watch mode and reloads on relevant source,
config, and bundled-plugin metadata changes.
### 2) Point the macOS app at your running Gateway

View File

@ -12,7 +12,7 @@
- 目标文档:`docs/zh-CN/**/*.md`
- 术语表:`docs/.i18n/glossary.zh-CN.json`
- 翻译记忆库:`docs/.i18n/zh-CN.tm.jsonl`
- 提示词规则:`scripts/docs-i18n/translator.go`
- 提示词规则:`scripts/docs-i18n/prompt.go`
常用运行方式:
@ -31,6 +31,8 @@ go run scripts/docs-i18n/main.go -mode segment docs/channels/matrix.md
注意事项:
- doc 模式用于整页翻译segment 模式用于小范围修补(依赖 TM
- 新增技术术语、页面标题或短导航标签时,先更新 `docs/.i18n/glossary.zh-CN.json`,再跑 `doc` 模式;不要指望模型自行保留英文术语或固定译名。
- `pnpm docs:check-i18n-glossary` 会检查变更过的英文文档标题和短内部链接标签是否已写入 glossary。
- 超大文件若超时,优先做**定点替换**或拆分后再跑。
- 翻译后检查中文引号、CJK-Latin 间距和术语一致性。

View File

@ -1,92 +0,0 @@
---
read_when:
- 制作或验证 OpenClaw macOS 发布版本
- 更新 Sparkle appcast 或订阅源资源
summary: OpenClaw macOS 发布清单Sparkle 订阅源、打包、签名)
title: macOS 发布
x-i18n:
generated_at: "2026-02-01T21:33:17Z"
model: claude-opus-4-5
provider: pi
source_hash: 703c08c13793cd8c96bd4c31fb4904cdf4ffff35576e7ea48a362560d371cb30
source_path: platforms/mac/release.md
workflow: 15
---
# OpenClaw macOS 发布Sparkle
本应用现已支持 Sparkle 自动更新。发布构建必须经过 Developer ID 签名、压缩,并发布包含签名的 appcast 条目。
## 前提条件
- 已安装 Developer ID Application 证书(示例:`Developer ID Application: <Developer Name> (<TEAMID>)`)。
- 环境变量 `SPARKLE_PRIVATE_KEY_FILE` 已设置为 Sparkle ed25519 私钥路径(公钥已嵌入 Info.plist。如果缺失请检查 `~/.profile`
- 用于 `xcrun notarytool` 的公证凭据(钥匙串配置文件或 API 密钥),以实现通过 Gatekeeper 安全分发的 DMG/zip。
- 我们使用名为 `openclaw-notary` 的钥匙串配置文件,由 shell 配置文件中的 App Store Connect API 密钥环境变量创建:
- `APP_STORE_CONNECT_API_KEY_P8``APP_STORE_CONNECT_KEY_ID``APP_STORE_CONNECT_ISSUER_ID`
- `echo "$APP_STORE_CONNECT_API_KEY_P8" | sed 's/\\n/\n/g' > /tmp/openclaw-notary.p8`
- `xcrun notarytool store-credentials "openclaw-notary" --key /tmp/openclaw-notary.p8 --key-id "$APP_STORE_CONNECT_KEY_ID" --issuer "$APP_STORE_CONNECT_ISSUER_ID"`
- 已安装 `pnpm` 依赖(`pnpm install --config.node-linker=hoisted`)。
- Sparkle 工具通过 SwiftPM 自动获取,位于 `apps/macos/.build/artifacts/sparkle/Sparkle/bin/``sign_update``generate_appcast` 等)。
## 构建与打包
注意事项:
- `APP_BUILD` 映射到 `CFBundleVersion`/`sparkle:version`;保持纯数字且单调递增(不含 `-beta`),否则 Sparkle 会将其视为相同版本。
- 默认为当前架构(`$(uname -m)`)。对于发布/通用构建,设置 `BUILD_ARCHS="arm64 x86_64"`(或 `BUILD_ARCHS=all`)。
- 使用 `scripts/package-mac-dist.sh` 生成发布产物zip + DMG + 公证)。使用 `scripts/package-mac-app.sh` 进行本地/开发打包。
```bash
# 从仓库根目录运行;设置发布 ID 以启用 Sparkle 订阅源。
# APP_BUILD 必须为纯数字且单调递增,以便 Sparkle 正确比较。
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.1.27-beta.1 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-app.sh
# 打包用于分发的 zip包含资源分支以支持 Sparkle 增量更新)
ditto -c -k --sequesterRsrc --keepParent dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.zip
# 可选:同时构建适合用户使用的样式化 DMG拖拽到 /Applications
scripts/create-dmg.sh dist/OpenClaw.app dist/OpenClaw-2026.1.27-beta.1.dmg
# 推荐:构建 + 公证/装订 zip + DMG
# 首先,创建一次钥匙串配置文件:
# xcrun notarytool store-credentials "openclaw-notary" \
# --apple-id "<apple-id>" --team-id "<team-id>" --password "<app-specific-password>"
NOTARIZE=1 NOTARYTOOL_PROFILE=openclaw-notary \
BUNDLE_ID=bot.molt.mac \
APP_VERSION=2026.1.27-beta.1 \
APP_BUILD="$(git rev-list --count HEAD)" \
BUILD_CONFIG=release \
SIGN_IDENTITY="Developer ID Application: <Developer Name> (<TEAMID>)" \
scripts/package-mac-dist.sh
# 可选:随发布一起提供 dSYM
ditto -c -k --keepParent apps/macos/.build/release/OpenClaw.app.dSYM dist/OpenClaw-2026.1.27-beta.1.dSYM.zip
```
## Appcast 条目
使用发布说明生成器,以便 Sparkle 渲染格式化的 HTML 说明:
```bash
SPARKLE_PRIVATE_KEY_FILE=/path/to/ed25519-private-key scripts/make_appcast.sh dist/OpenClaw-2026.1.27-beta.1.zip https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml
```
`CHANGELOG.md`(通过 [`scripts/changelog-to-html.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/changelog-to-html.sh))生成 HTML 发布说明,并将其嵌入 appcast 条目。
发布时,将更新后的 `appcast.xml` 与发布资源zip + dSYM一起提交。
## 发布与验证
- 将 `OpenClaw-2026.1.27-beta.1.zip`(和 `OpenClaw-2026.1.27-beta.1.dSYM.zip`)上传到标签 `v2026.1.27-beta.1` 对应的 GitHub 发布。
- 确保原始 appcast URL 与内置的订阅源匹配:`https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml`
- 完整性检查:
- `curl -I https://raw.githubusercontent.com/openclaw/openclaw/main/appcast.xml` 返回 200。
- `curl -I <enclosure url>` 在资源上传后返回 200。
- 在之前的公开构建版本上,从 About 选项卡运行"Check for Updates…",验证 Sparkle 能正常安装新构建。
完成定义:已签名的应用 + appcast 已发布,从旧版本的更新流程正常工作,且发布资源已附加到 GitHub 发布。

View File

@ -1,123 +1,48 @@
---
read_when:
- 发布新的 npm 版本
- 发布新的 macOS 应用版本
- 发布前验证元数据
summary: npm + macOS 应用的逐步发布清单
- 查找公开发布渠道的定义
- 查找版本命名与发布节奏
summary: 公开发布渠道、版本命名与发布节奏
title: 发布策略
x-i18n:
generated_at: "2026-02-03T10:09:28Z"
model: claude-opus-4-5
generated_at: "2026-03-15T19:23:11Z"
model: claude-opus-4-6
provider: pi
source_hash: 1a684bc26665966eb3c9c816d58d18eead008fd710041181ece38c21c5ff1c62
source_hash: df332d3169de7099661725d9266955456e80fc3d3ff95cb7aaf9997a02f0baaf
source_path: reference/RELEASING.md
workflow: 15
---
# 发布清单npm + macOS
# 发布策略
从仓库根目录使用 `pnpm`Node 22+)。在打标签/发布前保持工作树干净。
OpenClaw 有三个公开发布渠道:
## 操作员触发
- stable带标签的正式发布发布到 npm `latest`
- beta预发布标签发布到 npm `beta`
- dev`main` 分支的最新提交
当操作员说"release"时,立即执行此预检(除非遇到阻碍否则不要额外提问):
## 版本命名
- 阅读本文档和 `docs/platforms/mac/release.md`
- 从 `~/.profile` 加载环境变量并确认 `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect 变量已设置SPARKLE_PRIVATE_KEY_FILE 应位于 `~/.profile` 中)。
- 如需要,使用 `~/Library/CloudStorage/Dropbox/Backup/Sparkle` 中的 Sparkle 密钥。
- 正式发布版本号:`YYYY.M.D`
- Git 标签:`vYYYY.M.D`
- Beta 预发布版本号:`YYYY.M.D-beta.N`
- Git 标签:`vYYYY.M.D-beta.N`
- 月份和日期不补零
- `latest` 表示当前 npm 正式发布版本
- `beta` 表示当前 npm 预发布版本
- Beta 版本可能会在 macOS 应用跟进之前发布
1. **版本和元数据**
## 发布节奏
- [ ] 更新 `package.json` 版本(例如 `2026.1.29`)。
- [ ] 运行 `pnpm plugins:sync` 以对齐扩展包版本和变更日志。
- [ ] 更新 CLI/版本字符串:[`src/cli/program.ts`](https://github.com/openclaw/openclaw/blob/main/src/cli/program.ts) 和 [`src/provider-web.ts`](https://github.com/openclaw/openclaw/blob/main/src/provider-web.ts) 中的 Baileys user agent。
- [ ] 确认包元数据name、description、repository、keywords、license以及 `bin` 映射指向 [`openclaw.mjs`](https://github.com/openclaw/openclaw/blob/main/openclaw.mjs) 作为 `openclaw`
- [ ] 如果依赖项有变化,运行 `pnpm install` 确保 `pnpm-lock.yaml` 是最新的。
- 发布遵循 beta 优先原则
- 仅在最新的 beta 版本验证通过后才会发布正式版本
- 详细的发布流程、审批、凭证和恢复说明仅限维护者查阅
2. **构建和产物**
## 公开参考
- [ ] 如果 A2UI 输入有变化,运行 `pnpm canvas:a2ui:bundle` 并提交更新后的 [`src/canvas-host/a2ui/a2ui.bundle.js`](https://github.com/openclaw/openclaw/blob/main/src/canvas-host/a2ui/a2ui.bundle.js)。
- [ ] `pnpm run build`(重新生成 `dist/`)。
- [ ] 验证 npm 包的 `files` 包含所有必需的 `dist/*` 文件夹(特别是用于 headless node + ACP CLI 的 `dist/node-host/**``dist/acp/**`)。
- [ ] 确认 `dist/build-info.json` 存在并包含预期的 `commit` 哈希CLI 横幅在 npm 安装时使用此信息)。
- [ ] 可选:构建后运行 `npm pack --pack-destination /tmp`;检查 tarball 内容并保留以备 GitHub 发布使用(**不要**提交它)。
- [`.github/workflows/openclaw-npm-release.yml`](https://github.com/openclaw/openclaw/blob/main/.github/workflows/openclaw-npm-release.yml)
- [`scripts/openclaw-npm-release-check.ts`](https://github.com/openclaw/openclaw/blob/main/scripts/openclaw-npm-release-check.ts)
3. **变更日志和文档**
- [ ] 更新 `CHANGELOG.md`,添加面向用户的亮点(如果文件不存在则创建);按版本严格降序排列条目。
- [ ] 确保 README 示例/标志与当前 CLI 行为匹配(特别是新命令或选项)。
4. **验证**
- [ ] `pnpm build`
- [ ] `pnpm check`
- [ ] `pnpm test`(如需覆盖率输出则使用 `pnpm test:coverage`
- [ ] `pnpm release:check`(验证 npm pack 内容)
- [ ] `OPENCLAW_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke`Docker 安装冒烟测试,快速路径;发布前必需)
- 如果已知上一个 npm 发布版本有问题,为预安装步骤设置 `OPENCLAW_INSTALL_SMOKE_PREVIOUS=<last-good-version>``OPENCLAW_INSTALL_SMOKE_SKIP_PREVIOUS=1`
- [ ](可选)完整安装程序冒烟测试(添加非 root + CLI 覆盖):`pnpm test:install:smoke`
- [ ](可选)安装程序 E2EDocker运行 `curl -fsSL https://openclaw.ai/install.sh | bash`,新手引导,然后运行真实工具调用):
- `pnpm test:install:e2e:openai`(需要 `OPENAI_API_KEY`
- `pnpm test:install:e2e:anthropic`(需要 `ANTHROPIC_API_KEY`
- `pnpm test:install:e2e`(需要两个密钥;运行两个提供商)
- [ ](可选)如果你的更改影响发送/接收路径,抽查 Web Gateway 网关。
5. **macOS 应用Sparkle**
- [ ] 构建并签名 macOS 应用,然后压缩以供分发。
- [ ] 生成 Sparkle appcast通过 [`scripts/make_appcast.sh`](https://github.com/openclaw/openclaw/blob/main/scripts/make_appcast.sh) 生成 HTML 注释)并更新 `appcast.xml`
- [ ] 保留应用 zip和可选的 dSYM zip以便附加到 GitHub 发布。
- [ ] 按照 [macOS 发布](/platforms/mac/release) 获取确切命令和所需环境变量。
- `APP_BUILD` 必须是数字且单调递增(不带 `-beta`),以便 Sparkle 正确比较版本。
- 如果进行公证,使用从 App Store Connect API 环境变量创建的 `openclaw-notary` 钥匙串配置文件(参见 [macOS 发布](/platforms/mac/release))。
6. **发布npm**
- [ ] 确认 git 状态干净;根据需要提交并推送。
- [ ] 如需要,`npm login`(验证 2FA
- [ ] `npm publish --access public`(预发布版本使用 `--tag beta`)。
- [ ] 验证注册表:`npm view openclaw version``npm view openclaw dist-tags``npx -y openclaw@X.Y.Z --version`(或 `--help`)。
### 故障排除(来自 2.0.0-beta2 发布的笔记)
- **npm pack/publish 挂起或产生巨大 tarball**`dist/OpenClaw.app` 中的 macOS 应用包(和发布 zip被扫入包中。通过 `package.json``files` 白名单发布内容来修复(包含 dist 子目录、docs、skills排除应用包。用 `npm pack --dry-run` 确认 `dist/OpenClaw.app` 未列出。
- **npm auth dist-tags 的 Web 循环**:使用旧版认证以获取 OTP 提示:
- `NPM_CONFIG_AUTH_TYPE=legacy npm dist-tag add openclaw@X.Y.Z latest`
- **`npx` 验证失败并显示 `ECOMPROMISED: Lock compromised`**:使用新缓存重试:
- `NPM_CONFIG_CACHE=/tmp/npm-cache-$(date +%s) npx -y openclaw@X.Y.Z --version`
- **延迟修复后需要重新指向标签**:强制更新并推送标签,然后确保 GitHub 发布资产仍然匹配:
- `git tag -f vX.Y.Z && git push -f origin vX.Y.Z`
7. **GitHub 发布 + appcast**
- [ ] 打标签并推送:`git tag vX.Y.Z && git push origin vX.Y.Z`(或 `git push --tags`)。
- [ ] 为 `vX.Y.Z` 创建/刷新 GitHub 发布,**标题为 `openclaw X.Y.Z`**(不仅仅是标签);正文应包含该版本的**完整**变更日志部分(亮点 + 更改 + 修复),内联显示(无裸链接),且**不得在正文中重复标题**。
- [ ] 附加产物:`npm pack` tarball可选`OpenClaw-X.Y.Z.zip``OpenClaw-X.Y.Z.dSYM.zip`(如果生成)。
- [ ] 提交更新后的 `appcast.xml` 并推送Sparkle 从 main 获取源)。
- [ ] 从干净的临时目录(无 `package.json`),运行 `npx -y openclaw@X.Y.Z send --help` 确认安装/CLI 入口点正常工作。
- [ ] 宣布/分享发布说明。
## 插件发布范围npm
我们只发布 `@openclaw/*` 范围下的**现有 npm 插件**。不在 npm 上的内置插件保持**仅磁盘树**(仍在 `extensions/**` 中发布)。
获取列表的流程:
1. `npm search @openclaw --json` 并捕获包名。
2. 与 `extensions/*/package.json` 名称比较。
3. 只发布**交集**(已在 npm 上)。
当前 npm 插件列表(根据需要更新):
- @openclaw/bluebubbles
- @openclaw/diagnostics-otel
- @openclaw/discord
- @openclaw/lobster
- @openclaw/matrix
- @openclaw/msteams
- @openclaw/nextcloud-talk
- @openclaw/nostr
- @openclaw/voice-call
- @openclaw/zalo
- @openclaw/zalouser
发布说明还必须标注**默认未启用**的**新可选内置插件**(例如:`tlon`)。
维护者使用
[`openclaw/maintainers/release/README.md`](https://github.com/openclaw/maintainers/blob/main/release/README.md)
中的私有发布文档作为实际操作手册。

View File

@ -1,20 +1,24 @@
---
read_when:
- 你想要一份完整的文档地图
summary: 链接到每篇 OpenClaw 文档的导航中心
summary: 链接到所有 OpenClaw 文档的导航中心
title: 文档导航中心
x-i18n:
generated_at: "2026-02-04T17:55:29Z"
model: claude-opus-4-5
generated_at: "2026-03-15T19:29:16Z"
model: claude-opus-4-6
provider: pi
source_hash: c4b4572b64d36c9690988b8f964b0712f551ee6491b18a493701a17d2d352cb4
source_hash: e12e8b7881311fdaf08cd297392911dfa30dc46031a7038b6bb9011d166b1669
source_path: start/hubs.md
workflow: 15
---
# 文档导航中心
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们不一定出现在左侧导航栏中。
<Note>
如果你是 OpenClaw 新用户,请从[入门指南](/start/getting-started)开始。
</Note>
使用这些导航中心发现每一个页面,包括深入解析和参考文档——它们可能不会出现在左侧导航栏中。
## 从这里开始
@ -75,7 +79,6 @@ x-i18n:
- [模型提供商中心](/providers/models)
- [WhatsApp](/channels/whatsapp)
- [Telegram](/channels/telegram)
- [TelegramgrammY 注意事项)](/channels/grammy)
- [Slack](/channels/slack)
- [Discord](/channels/discord)
- [Mattermost](/channels/mattermost)(插件)
@ -113,17 +116,18 @@ x-i18n:
- [OpenProse](/prose)
- [CLI 参考](/cli)
- [Exec 工具](/tools/exec)
- [PDF 工具](/tools/pdf)
- [提权模式](/tools/elevated)
- [定时任务](/automation/cron-jobs)
- [定时任务 vs 心跳](/automation/cron-vs-heartbeat)
- [思考 + 详细输出](/tools/thinking)
- [模型](/concepts/models)
- [子智能体](/tools/subagents)
- [Agent send CLI](/tools/agent-send)
- [智能体发送 CLI](/tools/agent-send)
- [终端界面](/web/tui)
- [浏览器控制](/tools/browser)
- [浏览器Linux 故障排除)](/tools/browser-linux-troubleshooting)
- [轮询](/automation/poll)
- [投票](/automation/poll)
## 节点、媒体、语音
@ -160,7 +164,6 @@ x-i18n:
- [macOS 权限](/platforms/mac/permissions)
- [macOS 远程](/platforms/mac/remote)
- [macOS 签名](/platforms/mac/signing)
- [macOS 发布](/platforms/mac/release)
- [macOS Gateway 网关 (launchd)](/platforms/mac/bundled-gateway)
- [macOS XPC](/platforms/mac/xpc)
- [macOS Skills](/platforms/mac/skills)
@ -183,8 +186,6 @@ x-i18n:
## 实验(探索性)
- [新手引导配置协议](/experiments/onboarding-config-protocol)
- [定时任务加固笔记](/experiments/plans/cron-add-hardening)
- [群组策略加固笔记](/experiments/plans/group-policy-hardening)
- [研究:记忆](/experiments/research/memory)
- [模型配置探索](/experiments/proposals/model-config)
@ -195,5 +196,5 @@ x-i18n:
## 测试 + 发布
- [测试](/reference/test)
- [发布检查清单](/reference/RELEASING)
- [发布策略](/reference/RELEASING)
- [设备型号](/reference/device-models)

View File

@ -231,7 +231,7 @@
"build:strict-smoke": "pnpm canvas:a2ui:bundle && node scripts/tsdown-build.mjs && node scripts/copy-plugin-sdk-root-alias.mjs && pnpm build:plugin-sdk:dts",
"canvas:a2ui:bundle": "bash scripts/bundle-a2ui.sh",
"check": "pnpm check:host-env-policy:swift && pnpm format:check && pnpm tsgo && pnpm lint && pnpm lint:tmp:no-random-messaging && pnpm lint:tmp:channel-agnostic-boundaries && pnpm lint:tmp:no-raw-channel-fetch && pnpm lint:agent:ingress-owner && pnpm lint:plugins:no-register-http-handler && pnpm lint:plugins:no-monolithic-plugin-sdk-entry-imports && pnpm lint:webhook:no-low-level-body-read && pnpm lint:auth:no-pairing-store-group && pnpm lint:auth:pairing-account-scope",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-links",
"check:docs": "pnpm format:docs:check && pnpm lint:docs && pnpm docs:check-i18n-glossary && pnpm docs:check-links",
"check:host-env-policy:swift": "node scripts/generate-host-env-security-policy-swift.mjs --check",
"check:loc": "node --import tsx scripts/check-ts-max-loc.ts --max 500",
"config:docs:check": "node --import tsx scripts/generate-config-doc-baseline.ts --check",
@ -246,6 +246,7 @@
"deadcode:ts-unused": "pnpm dlx ts-unused-exports tsconfig.json --ignoreTestFiles --exitWithCount",
"dev": "node scripts/run-node.mjs",
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:check-i18n-glossary": "node scripts/check-docs-i18n-glossary.mjs",
"docs:check-links": "node scripts/docs-link-audit.mjs",
"docs:dev": "cd docs && mint dev",
"docs:list": "node scripts/docs-list.js",

View File

@ -0,0 +1,237 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const ROOT = process.cwd();
const GLOSSARY_PATH = path.join(ROOT, "docs", ".i18n", "glossary.zh-CN.json");
const DOC_FILE_RE = /^docs\/(?!zh-CN\/).+\.(md|mdx)$/i;
const LIST_ITEM_LINK_RE = /^\s*(?:[-*]|\d+\.)\s+\[([^\]]+)\]\((\/[^)]+)\)/;
const MAX_TITLE_WORDS = 8;
const MAX_LABEL_WORDS = 6;
const MAX_TERM_LENGTH = 80;
/**
* @typedef {{
* file: string;
* line: number;
* kind: "title" | "link label";
* term: string;
* }} TermMatch
*/
function parseArgs(argv) {
/** @type {{ base: string; head: string }} */
const args = { base: "", head: "" };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === "--base") {
args.base = argv[i + 1] ?? "";
i += 1;
continue;
}
if (argv[i] === "--head") {
args.head = argv[i + 1] ?? "";
i += 1;
}
}
return args;
}
function runGit(args) {
return execFileSync("git", args, {
cwd: ROOT,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
}).trim();
}
function resolveBase(explicitBase) {
if (explicitBase) {
return explicitBase;
}
const envBase = process.env.DOCS_I18N_GLOSSARY_BASE?.trim();
if (envBase) {
return envBase;
}
for (const candidate of ["origin/main", "fork/main", "main"]) {
try {
return runGit(["merge-base", candidate, "HEAD"]);
} catch {
// Try the next candidate.
}
}
return "";
}
function listChangedDocs(base, head) {
const args = ["diff", "--name-only", "--diff-filter=ACMR", base];
if (head) {
args.push(head);
}
args.push("--", "docs");
return runGit(args)
.split("\n")
.map((line) => line.trim())
.filter((line) => DOC_FILE_RE.test(line));
}
function loadGlossarySources() {
const data = fs.readFileSync(GLOSSARY_PATH, "utf8");
const entries = JSON.parse(data);
return new Set(entries.map((entry) => String(entry.source || "").trim()).filter(Boolean));
}
function containsLatin(text) {
return /[A-Za-z]/.test(text);
}
function wordCount(text) {
return text.trim().split(/\s+/).filter(Boolean).length;
}
function unquoteScalar(raw) {
const value = raw.trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
return value.slice(1, -1).trim();
}
return value;
}
function isGlossaryCandidate(term, maxWords) {
if (!term) {
return false;
}
if (!containsLatin(term)) {
return false;
}
if (term.includes("`")) {
return false;
}
if (term.length > MAX_TERM_LENGTH) {
return false;
}
return wordCount(term) <= maxWords;
}
function readGitFile(base, relPath) {
try {
return runGit(["show", `${base}:${relPath}`]);
} catch {
return "";
}
}
/**
* @param {string} file
* @param {string} text
* @returns {Map<string, TermMatch>}
*/
function extractTerms(file, text) {
/** @type {Map<string, TermMatch>} */
const terms = new Map();
const lines = text.split("\n");
if (lines[0]?.trim() === "---") {
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index];
if (line.trim() === "---") {
break;
}
const match = line.match(/^title:\s*(.+)\s*$/);
if (!match) {
continue;
}
const title = unquoteScalar(match[1]);
if (isGlossaryCandidate(title, MAX_TITLE_WORDS)) {
terms.set(title, { file, line: index + 1, kind: "title", term: title });
}
break;
}
}
for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(LIST_ITEM_LINK_RE);
if (!match) {
continue;
}
const label = match[1].trim();
if (!isGlossaryCandidate(label, MAX_LABEL_WORDS)) {
continue;
}
if (!terms.has(label)) {
terms.set(label, { file, line: index + 1, kind: "link label", term: label });
}
}
return terms;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const base = resolveBase(args.base);
if (!base) {
console.warn(
"docs:check-i18n-glossary: no merge base found; skipping glossary coverage check.",
);
process.exit(0);
}
const changedDocs = listChangedDocs(base, args.head);
if (changedDocs.length === 0) {
process.exit(0);
}
const glossary = loadGlossarySources();
/** @type {TermMatch[]} */
const missing = [];
for (const relPath of changedDocs) {
const absPath = path.join(ROOT, relPath);
if (!fs.existsSync(absPath)) {
continue;
}
const currentTerms = extractTerms(relPath, fs.readFileSync(absPath, "utf8"));
const baseTerms = extractTerms(relPath, readGitFile(base, relPath));
for (const [term, match] of currentTerms) {
if (baseTerms.has(term)) {
continue;
}
if (glossary.has(term)) {
continue;
}
missing.push(match);
}
}
if (missing.length === 0) {
process.exit(0);
}
console.error("docs:check-i18n-glossary: missing zh-CN glossary entries for changed doc labels:");
for (const match of missing) {
console.error(`- ${match.file}:${match.line} ${match.kind} "${match.term}"`);
}
console.error("");
console.error(
"Add exact source terms to docs/.i18n/glossary.zh-CN.json before rerunning docs-i18n.",
);
console.error(`Checked changed English docs relative to ${base}.`);
process.exit(1);
}
main();

View File

@ -58,6 +58,11 @@ Rules:
- Do not remove, reorder, or summarize content.
- Use fluent, idiomatic technical Chinese; avoid slang or jokes.
- Use neutral documentation tone; prefer /你的, avoid /您的.
- Glossary terms are mandatory. When a source term matches a glossary entry, use
the glossary target exactly, including headings, link labels, and short
UI-style labels.
- If a glossary target is identical to the source text, preserve that term in
English exactly as written.
- Insert a space between Latin characters and CJK text (W3C CLREQ), e.g., Gateway 网关, Skills 配置.
- Use Chinese quotation marks and for Chinese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys.
- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal.
@ -90,6 +95,11 @@ Rules:
- Do not remove, reorder, or summarize content.
- Use fluent, idiomatic technical Japanese; avoid slang or jokes.
- Use neutral documentation tone; avoid overly formal honorifics (e.g., avoid でございます).
- Glossary terms are mandatory. When a source term matches a glossary entry, use
the glossary target exactly, including headings, link labels, and short
UI-style labels.
- If a glossary target is identical to the source text, preserve that term in
English exactly as written.
- Use Japanese quotation marks and for Japanese prose; keep ASCII quotes inside code spans/blocks or literal CLI/keys.
- Do not add or remove spacing around Latin text just because it borders Japanese; keep spacing stable unless required by Japanese grammar.
- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal.
@ -121,6 +131,11 @@ Rules:
- Do not remove, reorder, or summarize content.
- Use fluent, idiomatic technical language in the target language; avoid slang or jokes.
- Use neutral documentation tone.
- Glossary terms are mandatory. When a source term matches a glossary entry, use
the glossary target exactly, including headings, link labels, and short
UI-style labels.
- If a glossary target is identical to the source text, preserve that term in
English exactly as written.
- Keep product names in English: OpenClaw, Pi, WhatsApp, Telegram, Discord, iMessage, Slack, Microsoft Teams, Google Chat, Signal.
- Keep these terms in English: Skills, local loopback, Tailscale.
- Never output an empty response; if unsure, return the source text unchanged.
@ -135,7 +150,7 @@ func buildGlossaryPrompt(glossary []GlossaryEntry) string {
return ""
}
var lines []string
lines = append(lines, "Preferred translations (use when natural):")
lines = append(lines, "Required terminology (use exactly when the source term matches):")
for _, entry := range glossary {
if entry.Source == "" || entry.Target == "" {
continue

View File

@ -15,7 +15,7 @@ import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./s
export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts";
type PackFile = { path: string };
type PackResult = { files?: PackFile[] };
type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number };
const requiredPathGroups = [
["dist/index.js", "dist/index.mjs"],
@ -112,6 +112,10 @@ const requiredPathGroups = [
"dist/build-info.json",
];
const forbiddenPrefixes = ["dist/OpenClaw.app/"];
// 2026.3.12 ballooned to ~213.6 MiB unpacked and correlated with low-memory
// startup/doctor OOM reports. Keep enough headroom for the current pack while
// failing fast if duplicate/shim content sneaks back into the release artifact.
const npmPackUnpackedSizeBudgetBytes = 160 * 1024 * 1024;
const appcastPath = resolve("appcast.xml");
const laneBuildMin = 1_000_000_000;
const laneFloorAdoptionDateKey = 20260227;
@ -228,6 +232,50 @@ export function collectForbiddenPackPaths(paths: Iterable<string>): string[] {
.toSorted();
}
function formatMiB(bytes: number): string {
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
}
function resolvePackResultLabel(entry: PackResult, index: number): string {
return entry.filename?.trim() || `pack result #${index + 1}`;
}
function formatPackUnpackedSizeBudgetError(params: {
label: string;
unpackedSize: number;
}): string {
return [
`${params.label} unpackedSize ${params.unpackedSize} bytes (${formatMiB(params.unpackedSize)}) exceeds budget ${npmPackUnpackedSizeBudgetBytes} bytes (${formatMiB(npmPackUnpackedSizeBudgetBytes)}).`,
"Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
].join(" ");
}
export function collectPackUnpackedSizeErrors(results: Iterable<PackResult>): string[] {
const entries = Array.from(results);
const errors: string[] = [];
let checkedCount = 0;
for (const [index, entry] of entries.entries()) {
if (typeof entry.unpackedSize !== "number" || !Number.isFinite(entry.unpackedSize)) {
continue;
}
checkedCount += 1;
if (entry.unpackedSize <= npmPackUnpackedSizeBudgetBytes) {
continue;
}
const label = resolvePackResultLabel(entry, index);
errors.push(formatPackUnpackedSizeBudgetError({ label, unpackedSize: entry.unpackedSize }));
}
if (entries.length > 0 && checkedCount === 0) {
errors.push(
"npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.",
);
}
return errors;
}
function checkPluginVersions() {
const rootPackagePath = resolve("package.json");
const rootPackage = JSON.parse(readFileSync(rootPackagePath, "utf8")) as PackageJson;
@ -433,8 +481,9 @@ function main() {
})
.toSorted();
const forbidden = collectForbiddenPackPaths(paths);
const sizeErrors = collectPackUnpackedSizeErrors(results);
if (missing.length > 0 || forbidden.length > 0) {
if (missing.length > 0 || forbidden.length > 0 || sizeErrors.length > 0) {
if (missing.length > 0) {
console.error("release-check: missing files in npm pack:");
for (const path of missing) {
@ -447,6 +496,12 @@ function main() {
console.error(` - ${path}`);
}
}
if (sizeErrors.length > 0) {
console.error("release-check: npm pack unpacked size budget exceeded:");
for (const error of sizeErrors) {
console.error(` - ${error}`);
}
}
process.exit(1);
}

View File

@ -1,4 +1,6 @@
export const runNodeWatchedPaths: string[];
export function isBuildRelevantRunNodePath(repoPath: string): boolean;
export function isRestartRelevantRunNodePath(repoPath: string): boolean;
export function runNodeMain(params?: {
spawn?: (

View File

@ -8,7 +8,63 @@ import { pathToFileURL } from "node:url";
const compiler = "tsdown";
const compilerArgs = ["exec", compiler, "--no-clean"];
export const runNodeWatchedPaths = ["src", "tsconfig.json", "package.json"];
const runNodeSourceRoots = ["src", "extensions"];
const runNodeConfigFiles = ["tsconfig.json", "package.json", "tsdown.config.ts"];
export const runNodeWatchedPaths = [...runNodeSourceRoots, ...runNodeConfigFiles];
const extensionSourceFilePattern = /\.(?:[cm]?[jt]sx?)$/;
const extensionRestartMetadataFiles = new Set(["openclaw.plugin.json", "package.json"]);
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const isIgnoredSourcePath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
};
const isBuildRelevantSourcePath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
return extensionSourceFilePattern.test(normalizedPath) && !isIgnoredSourcePath(normalizedPath);
};
export const isBuildRelevantRunNodePath = (repoPath) => {
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
if (runNodeConfigFiles.includes(normalizedPath)) {
return true;
}
if (normalizedPath.startsWith("src/")) {
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
}
if (normalizedPath.startsWith("extensions/")) {
return isBuildRelevantSourcePath(normalizedPath.slice("extensions/".length));
}
return false;
};
const isRestartRelevantExtensionPath = (relativePath) => {
const normalizedPath = normalizePath(relativePath);
if (extensionRestartMetadataFiles.has(path.posix.basename(normalizedPath))) {
return true;
}
return isBuildRelevantSourcePath(normalizedPath);
};
export const isRestartRelevantRunNodePath = (repoPath) => {
const normalizedPath = normalizePath(repoPath).replace(/^\.\/+/, "");
if (runNodeConfigFiles.includes(normalizedPath)) {
return true;
}
if (normalizedPath.startsWith("src/")) {
return !isIgnoredSourcePath(normalizedPath.slice("src/".length));
}
if (normalizedPath.startsWith("extensions/")) {
return isRestartRelevantExtensionPath(normalizedPath.slice("extensions/".length));
}
return false;
};
const statMtime = (filePath, fsImpl = fs) => {
try {
@ -18,16 +74,12 @@ const statMtime = (filePath, fsImpl = fs) => {
}
};
const isExcludedSource = (filePath, srcRoot) => {
const relativePath = path.relative(srcRoot, filePath);
const isExcludedSource = (filePath, sourceRoot, sourceRootName) => {
const relativePath = normalizePath(path.relative(sourceRoot, filePath));
if (relativePath.startsWith("..")) {
return false;
}
return (
relativePath.endsWith(".test.ts") ||
relativePath.endsWith(".test.tsx") ||
relativePath.endsWith(`test-helpers.ts`)
);
return !isBuildRelevantRunNodePath(path.posix.join(sourceRootName, relativePath));
};
const findLatestMtime = (dirPath, shouldSkip, deps) => {
@ -89,15 +141,39 @@ const resolveGitHead = (deps) => {
return head || null;
};
const readGitStatus = (deps) => {
try {
const result = deps.spawnSync(
"git",
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
{
cwd: deps.cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
},
);
if (result.status !== 0) {
return null;
}
return result.stdout ?? "";
} catch {
return null;
}
};
const parseGitStatusPaths = (output) =>
output
.split("\n")
.flatMap((line) => line.slice(3).split(" -> "))
.map((entry) => normalizePath(entry.trim()))
.filter(Boolean);
const hasDirtySourceTree = (deps) => {
const output = runGit(
["status", "--porcelain", "--untracked-files=normal", "--", ...runNodeWatchedPaths],
deps,
);
const output = readGitStatus(deps);
if (output === null) {
return null;
}
return output.length > 0;
return parseGitStatusPaths(output).some((repoPath) => isBuildRelevantRunNodePath(repoPath));
};
const readBuildStamp = (deps) => {
@ -119,12 +195,18 @@ const readBuildStamp = (deps) => {
};
const hasSourceMtimeChanged = (stampMtime, deps) => {
const srcMtime = findLatestMtime(
deps.srcRoot,
(candidate) => isExcludedSource(candidate, deps.srcRoot),
deps,
);
return srcMtime != null && srcMtime > stampMtime;
let latestSourceMtime = null;
for (const sourceRoot of deps.sourceRoots) {
const sourceMtime = findLatestMtime(
sourceRoot.path,
(candidate) => isExcludedSource(candidate, sourceRoot.path, sourceRoot.name),
deps,
);
if (sourceMtime != null && (latestSourceMtime == null || sourceMtime > latestSourceMtime)) {
latestSourceMtime = sourceMtime;
}
}
return latestSourceMtime != null && latestSourceMtime > stampMtime;
};
const shouldBuild = (deps) => {
@ -223,8 +305,11 @@ export async function runNodeMain(params = {}) {
deps.distRoot = path.join(deps.cwd, "dist");
deps.distEntry = path.join(deps.distRoot, "/entry.js");
deps.buildStampPath = path.join(deps.distRoot, ".buildstamp");
deps.srcRoot = path.join(deps.cwd, "src");
deps.configFiles = [path.join(deps.cwd, "tsconfig.json"), path.join(deps.cwd, "package.json")];
deps.sourceRoots = runNodeSourceRoots.map((sourceRoot) => ({
name: sourceRoot,
path: path.join(deps.cwd, sourceRoot),
}));
deps.configFiles = runNodeConfigFiles.map((filePath) => path.join(deps.cwd, filePath));
if (!shouldBuild(deps)) {
return await runOpenClaw(deps);

View File

@ -1,26 +1,32 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import chokidar from "chokidar";
import { runNodeWatchedPaths } from "./run-node.mjs";
import { isRestartRelevantRunNodePath, runNodeWatchedPaths } from "./run-node.mjs";
const WATCH_NODE_RUNNER = "scripts/run-node.mjs";
const WATCH_RESTART_SIGNAL = "SIGTERM";
const buildRunnerArgs = (args) => [WATCH_NODE_RUNNER, ...args];
const normalizePath = (filePath) => String(filePath ?? "").replaceAll("\\", "/");
const normalizePath = (filePath) =>
String(filePath ?? "")
.replaceAll("\\", "/")
.replace(/^\.\/+/, "");
const isIgnoredWatchPath = (filePath) => {
const normalizedPath = normalizePath(filePath);
return (
normalizedPath.endsWith(".test.ts") ||
normalizedPath.endsWith(".test.tsx") ||
normalizedPath.endsWith("test-helpers.ts")
);
const resolveRepoPath = (filePath, cwd) => {
const rawPath = String(filePath ?? "");
if (path.isAbsolute(rawPath)) {
return normalizePath(path.relative(cwd, rawPath));
}
return normalizePath(rawPath);
};
const isIgnoredWatchPath = (filePath, cwd) =>
!isRestartRelevantRunNodePath(resolveRepoPath(filePath, cwd));
export async function runWatchMain(params = {}) {
const deps = {
spawn: params.spawn ?? spawn,
@ -52,7 +58,7 @@ export async function runWatchMain(params = {}) {
const watcher = deps.createWatcher(deps.watchPaths, {
ignoreInitial: true,
ignored: (watchPath) => isIgnoredWatchPath(watchPath),
ignored: (watchPath) => isIgnoredWatchPath(watchPath, deps.cwd),
});
const settle = (code) => {
@ -89,7 +95,7 @@ export async function runWatchMain(params = {}) {
};
const requestRestart = (changedPath) => {
if (shuttingDown || isIgnoredWatchPath(changedPath)) {
if (shuttingDown || isIgnoredWatchPath(changedPath, deps.cwd)) {
return;
}
if (!watchProcess) {

View File

@ -9,11 +9,17 @@ import type { ContextEngine } from "./types.js";
export type ContextEngineFactory = () => ContextEngine | Promise<ContextEngine>;
export type ContextEngineRegistrationResult = { ok: true } | { ok: false; existingOwner: string };
type RegisterContextEngineForOwnerOptions = {
allowSameOwnerRefresh?: boolean;
};
// ---------------------------------------------------------------------------
// Registry (module-level singleton)
// ---------------------------------------------------------------------------
const CONTEXT_ENGINE_REGISTRY_STATE = Symbol.for("openclaw.contextEngineRegistryState");
const CORE_CONTEXT_ENGINE_OWNER = "core";
const PUBLIC_CONTEXT_ENGINE_OWNER = "public-sdk";
type ContextEngineRegistryState = {
engines: Map<
@ -39,22 +45,54 @@ function getContextEngineRegistryState(): ContextEngineRegistryState {
return globalState[CONTEXT_ENGINE_REGISTRY_STATE];
}
function requireContextEngineOwner(owner: string): string {
const normalizedOwner = owner.trim();
if (!normalizedOwner) {
throw new Error(`registerContextEngineForOwner: owner must be a non-empty string, got ${JSON.stringify(owner)}`);
}
return normalizedOwner;
}
/**
* Register a context engine implementation under the given id.
* Register a context engine implementation under an explicit trusted owner.
*/
export function registerContextEngineForOwner(
id: string,
factory: ContextEngineFactory,
owner: string,
opts?: RegisterContextEngineForOwnerOptions,
): ContextEngineRegistrationResult {
const normalizedOwner = requireContextEngineOwner(owner);
const registry = getContextEngineRegistryState().engines;
const existing = registry.get(id);
if (
id === defaultSlotIdForKey("contextEngine") &&
normalizedOwner !== CORE_CONTEXT_ENGINE_OWNER
) {
return { ok: false, existingOwner: CORE_CONTEXT_ENGINE_OWNER };
}
if (existing && existing.owner !== normalizedOwner) {
return { ok: false, existingOwner: existing.owner };
}
if (existing && opts?.allowSameOwnerRefresh !== true) {
return { ok: false, existingOwner: existing.owner };
}
registry.set(id, { factory, owner: normalizedOwner });
return { ok: true };
}
/**
* Public SDK entry point for third-party registrations.
*
* This path is intentionally unprivileged: it cannot claim core-owned ids and
* it cannot safely refresh an existing registration because the caller's
* identity is not authenticated.
*/
export function registerContextEngine(
id: string,
factory: ContextEngineFactory,
opts?: { owner?: string },
): ContextEngineRegistrationResult {
const owner = opts?.owner?.trim() || "core";
const registry = getContextEngineRegistryState().engines;
const existing = registry.get(id);
if (existing && existing.owner !== owner) {
return { ok: false, existingOwner: existing.owner };
}
registry.set(id, { factory, owner });
return { ok: true };
return registerContextEngineForOwner(id, factory, PUBLIC_CONTEXT_ENGINE_OWNER);
}
/**

View File

@ -24,6 +24,12 @@ function createExitedProcess(code: number | null, signal: string | null = null)
};
}
function expectedBuildSpawn(platform: NodeJS.Platform = process.platform) {
return platform === "win32"
? ["cmd.exe", "/d", "/s", "/c", "pnpm", "exec", "tsdown", "--no-clean"]
: ["pnpm", "exec", "tsdown", "--no-clean"];
}
describe("run-node script", () => {
it.runIf(process.platform !== "win32")(
"preserves control-ui assets by building with tsdown --no-clean",
@ -161,4 +167,360 @@ describe("run-node script", () => {
expect(exitCode).toBe(23);
});
});
it("rebuilds when extension sources are newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const extensionPath = path.join(tmp, "extensions", "demo", "src", "index.ts");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
await fs.mkdir(path.dirname(extensionPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(extensionPath, "export const extensionValue = 1;\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(extensionPath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
expectedBuildSpawn(),
[process.execPath, "openclaw.mjs", "status"],
]);
});
});
it("skips rebuilding when extension package metadata is newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const packagePath = path.join(tmp, "extensions", "demo", "package.json");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(packagePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(
packagePath,
'{"name":"demo","openclaw":{"extensions":["./index.ts"]}}\n',
"utf-8",
);
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(tsdownConfigPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(packagePath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("skips rebuilding for dirty non-source files under extensions", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const readmePath = path.join(tmp, "extensions", "demo", "README.md");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(readmePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(readmePath, "# demo\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
await fs.utimes(srcPath, stampTime, stampTime);
await fs.utimes(readmePath, stampTime, stampTime);
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: " M extensions/demo/README.md\n" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("skips rebuilding for dirty extension manifests that only affect runtime reload", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const manifestPath = path.join(tmp, "extensions", "demo", "openclaw.plugin.json");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(manifestPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(manifestPath, '{"id":"demo"}\n', "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
await fs.utimes(srcPath, stampTime, stampTime);
await fs.utimes(manifestPath, stampTime, stampTime);
await fs.utimes(tsconfigPath, stampTime, stampTime);
await fs.utimes(packageJsonPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, stampTime, stampTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: " M extensions/demo/openclaw.plugin.json\n" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("skips rebuilding when only non-source extension files are newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const readmePath = path.join(tmp, "extensions", "demo", "README.md");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(readmePath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(readmePath, "# demo\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(srcPath, oldTime, oldTime);
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(tsdownConfigPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(readmePath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = () => ({ status: 1, stdout: "" });
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([[process.execPath, "openclaw.mjs", "status"]]);
});
});
it("rebuilds when tsdown config is newer than the build stamp", async () => {
await withTempDir(async (tmp) => {
const srcPath = path.join(tmp, "src", "index.ts");
const distEntryPath = path.join(tmp, "dist", "entry.js");
const buildStampPath = path.join(tmp, "dist", ".buildstamp");
const tsconfigPath = path.join(tmp, "tsconfig.json");
const packageJsonPath = path.join(tmp, "package.json");
const tsdownConfigPath = path.join(tmp, "tsdown.config.ts");
await fs.mkdir(path.dirname(srcPath), { recursive: true });
await fs.mkdir(path.dirname(distEntryPath), { recursive: true });
await fs.writeFile(srcPath, "export const value = 1;\n", "utf-8");
await fs.writeFile(tsconfigPath, "{}\n", "utf-8");
await fs.writeFile(packageJsonPath, '{"name":"openclaw-test"}\n', "utf-8");
await fs.writeFile(tsdownConfigPath, "export default {};\n", "utf-8");
await fs.writeFile(distEntryPath, "console.log('built');\n", "utf-8");
await fs.writeFile(buildStampPath, '{"head":"abc123"}\n', "utf-8");
const oldTime = new Date("2026-03-13T10:00:00.000Z");
const stampTime = new Date("2026-03-13T12:00:00.000Z");
const newTime = new Date("2026-03-13T12:00:01.000Z");
await fs.utimes(srcPath, oldTime, oldTime);
await fs.utimes(tsconfigPath, oldTime, oldTime);
await fs.utimes(packageJsonPath, oldTime, oldTime);
await fs.utimes(distEntryPath, stampTime, stampTime);
await fs.utimes(buildStampPath, stampTime, stampTime);
await fs.utimes(tsdownConfigPath, newTime, newTime);
const spawnCalls: string[][] = [];
const spawn = (cmd: string, args: string[]) => {
spawnCalls.push([cmd, ...args]);
return createExitedProcess(0);
};
const spawnSync = (cmd: string, args: string[]) => {
if (cmd === "git" && args[0] === "rev-parse") {
return { status: 0, stdout: "abc123\n" };
}
if (cmd === "git" && args[0] === "status") {
return { status: 0, stdout: "" };
}
return { status: 1, stdout: "" };
};
const { runNodeMain } = await import("../../scripts/run-node.mjs");
const exitCode = await runNodeMain({
cwd: tmp,
args: ["status"],
env: {
...process.env,
OPENCLAW_RUNNER_LOG: "0",
},
spawn,
spawnSync,
execPath: process.execPath,
platform: process.platform,
});
expect(exitCode).toBe(0);
expect(spawnCalls).toEqual([
expectedBuildSpawn(),
[process.execPath, "openclaw.mjs", "status"],
]);
});
});
});

View File

@ -44,10 +44,17 @@ describe("watch-node script", () => {
{ ignoreInitial: boolean; ignored: (watchPath: string) => boolean },
];
expect(watchPaths).toEqual(runNodeWatchedPaths);
expect(watchPaths).toContain("extensions");
expect(watchPaths).toContain("tsdown.config.ts");
expect(watchOptions.ignoreInitial).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node.test.ts")).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node.test.tsx")).toBe(true);
expect(watchOptions.ignored("src/infra/watch-node-test-helpers.ts")).toBe(true);
expect(watchOptions.ignored("extensions/voice-call/README.md")).toBe(true);
expect(watchOptions.ignored("extensions/voice-call/openclaw.plugin.json")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/package.json")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/index.ts")).toBe(false);
expect(watchOptions.ignored("extensions/voice-call/src/runtime.ts")).toBe(false);
expect(watchOptions.ignored("src/infra/watch-node.ts")).toBe(false);
expect(watchOptions.ignored("tsconfig.json")).toBe(false);
@ -120,9 +127,24 @@ describe("watch-node script", () => {
}),
});
const childB = Object.assign(new EventEmitter(), {
kill: vi.fn(function () {
queueMicrotask(() => childB.emit("exit", 0, null));
}),
});
const childC = Object.assign(new EventEmitter(), {
kill: vi.fn(function () {
queueMicrotask(() => childC.emit("exit", 0, null));
}),
});
const childD = Object.assign(new EventEmitter(), {
kill: vi.fn(() => {}),
});
const spawn = vi.fn().mockReturnValueOnce(childA).mockReturnValueOnce(childB);
const spawn = vi
.fn()
.mockReturnValueOnce(childA)
.mockReturnValueOnce(childB)
.mockReturnValueOnce(childC)
.mockReturnValueOnce(childD);
const watcher = Object.assign(new EventEmitter(), {
close: vi.fn(async () => {}),
});
@ -151,11 +173,26 @@ describe("watch-node script", () => {
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
watcher.emit("change", "src/infra/watch-node.ts");
watcher.emit("change", "extensions/voice-call/README.md");
await new Promise((resolve) => setImmediate(resolve));
expect(spawn).toHaveBeenCalledTimes(1);
expect(childA.kill).not.toHaveBeenCalled();
watcher.emit("change", "extensions/voice-call/openclaw.plugin.json");
await new Promise((resolve) => setImmediate(resolve));
expect(childA.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(2);
watcher.emit("change", "extensions/voice-call/package.json");
await new Promise((resolve) => setImmediate(resolve));
expect(childB.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(3);
watcher.emit("change", "src/infra/watch-node.ts");
await new Promise((resolve) => setImmediate(resolve));
expect(childC.kill).toHaveBeenCalledWith("SIGTERM");
expect(spawn).toHaveBeenCalledTimes(4);
fakeProcess.emit("SIGINT");
const exitCode = await runPromise;
expect(exitCode).toBe(130);

View File

@ -4,12 +4,17 @@ import {
collectBundledExtensionManifestErrors,
collectBundledExtensionRootDependencyGapErrors,
collectForbiddenPackPaths,
collectPackUnpackedSizeErrors,
} from "../scripts/release-check.ts";
function makeItem(shortVersion: string, sparkleVersion: string): string {
return `<item><title>${shortVersion}</title><sparkle:shortVersionString>${shortVersion}</sparkle:shortVersionString><sparkle:version>${sparkleVersion}</sparkle:version></item>`;
}
function makePackResult(filename: string, unpackedSize: number) {
return { filename, unpackedSize };
}
describe("collectAppcastSparkleVersionErrors", () => {
it("accepts legacy 9-digit calver builds before lane-floor cutover", () => {
const xml = `<rss><channel>${makeItem("2026.2.26", "202602260")}</channel></rss>`;
@ -163,3 +168,30 @@ describe("collectForbiddenPackPaths", () => {
).toEqual(["extensions/tlon/node_modules/.bin/tlon", "node_modules/.bin/openclaw"]);
});
});
describe("collectPackUnpackedSizeErrors", () => {
it("accepts pack results within the unpacked size budget", () => {
expect(
collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.14.tgz", 120_354_302)]),
).toEqual([]);
});
it("flags oversized pack results that risk low-memory startup failures", () => {
expect(
collectPackUnpackedSizeErrors([makePackResult("openclaw-2026.3.12.tgz", 224_002_564)]),
).toEqual([
"openclaw-2026.3.12.tgz unpackedSize 224002564 bytes (213.6 MiB) exceeds budget 167772160 bytes (160.0 MiB). Investigate duplicate channel shims, copied extension trees, or other accidental pack bloat before release.",
]);
});
it("fails closed when npm pack output omits unpackedSize for every result", () => {
expect(
collectPackUnpackedSizeErrors([
{ filename: "openclaw-2026.3.14.tgz" },
{ filename: "openclaw-extra.tgz", unpackedSize: Number.NaN },
]),
).toEqual([
"npm pack --dry-run produced no unpackedSize data; pack size budget was not verified.",
]);
});
});