feat: publish plugins with extended-stable releases (#100448)

* feat(release): publish plugins on extended-stable

* feat(plugins): align extended-stable with core

* docs: explain extended-stable plugin alignment

* fix(release): make plugin convergence recoverable

* fix(plugins): preserve extended-stable fallback intent

* fix(release): keep plugin tag mutation credential-isolated
This commit is contained in:
Kevin Lin 2026-07-05 16:16:26 -07:00 committed by GitHub
parent a4e728feca
commit 9d2d517296
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1258 additions and 81 deletions

View file

@ -24,6 +24,10 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
plugin_npm_run_id:
description: Successful Plugin NPM Release run id for the exact extended-stable branch and release SHA
required: false
type: string
npm_dist_tag:
description: npm dist-tag to publish to
required: true
@ -469,6 +473,7 @@ jobs:
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
PLUGIN_NPM_RUN_ID: ${{ inputs.plugin_npm_run_id }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
run: |
set -euo pipefail
@ -484,6 +489,10 @@ jobs:
exit 1
fi
fi
if [[ "${RELEASE_NPM_DIST_TAG}" == "extended-stable" && -z "${PLUGIN_NPM_RUN_ID// }" ]]; then
echo "Extended-stable publish requires plugin_npm_run_id from a successful Plugin NPM Release run." >&2
exit 1
fi
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" && "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Workflow-dispatched real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2
exit 1
@ -618,6 +627,21 @@ jobs:
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,headSha,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run
- name: Verify plugin npm release run metadata
if: ${{ inputs.npm_dist_tag == 'extended-stable' }}
env:
GH_TOKEN: ${{ github.token }}
PLUGIN_NPM_RUN_ID: ${{ inputs.plugin_npm_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
EXPECTED_EXTENDED_STABLE_BRANCH: ${{ github.ref_name }}
RUN_KIND: plugin
run: |
set -euo pipefail
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
export EXPECTED_RELEASE_SHA
RUN_JSON="$(gh run view "$PLUGIN_NPM_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,displayTitle,headBranch,headSha,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node scripts/openclaw-npm-extended-stable-release.mjs verify-run
- name: Download prepared npm tarball
env:
GH_TOKEN: ${{ github.token }}

View file

@ -1,4 +1,5 @@
name: Plugin NPM Release
run-name: ${{ github.event_name == 'workflow_dispatch' && format('Plugin NPM Release [{0}] {1}', inputs.npm_dist_tag, inputs.ref) || format('Plugin NPM Release [default] {0}', github.sha) }}
on:
push:
@ -8,6 +9,7 @@ on:
- ".github/workflows/plugin-npm-release.yml"
- "extensions/**"
- "package.json"
- "scripts/lib/npm-publish-plan.mjs"
- "scripts/lib/plugin-npm-package-manifest.mjs"
- "scripts/lib/plugin-npm-release.ts"
- "scripts/plugin-npm-publish.sh"
@ -25,7 +27,7 @@ on:
- selected
- all-publishable
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from
description: Commit SHA on main, a release branch, the canonical extended-stable branch, or the matching Tideclaw alpha branch to publish from
required: true
type: string
plugins:
@ -36,6 +38,14 @@ on:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
npm_dist_tag:
description: Optional npm dist-tag override
required: true
default: default
type: choice
options:
- default
- extended-stable
concurrency:
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.ref }}
@ -55,6 +65,7 @@ jobs:
has_candidates: ${{ steps.plan.outputs.has_candidates }}
candidate_count: ${{ steps.plan.outputs.candidate_count }}
matrix: ${{ steps.plan.outputs.matrix }}
all_matrix: ${{ steps.plan.outputs.all_matrix }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
@ -75,9 +86,37 @@ jobs:
- name: Validate ref is on a trusted publish branch
env:
NPM_DIST_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag || 'default' }}
PUBLISH_SCOPE: ${{ github.event_name == 'workflow_dispatch' && inputs.publish_scope || '' }}
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
SOURCE_REF: ${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ "${NPM_DIST_TAG}" == "extended-stable" ]]; then
if [[ "${PUBLISH_SCOPE}" != "all-publishable" || -n "${RELEASE_PLUGINS// }" ]]; then
echo "Extended-stable plugin publication requires publish_scope=all-publishable without an explicit plugin list." >&2
exit 1
fi
if [[ ! "${SOURCE_REF}" =~ ^[0-9a-f]{40}$ ]] || [[ "$(git rev-parse HEAD)" != "$(git rev-parse "${SOURCE_REF}^{commit}")" ]]; then
echo "Extended-stable plugin publication requires ref to be the exact 40-character source SHA." >&2
exit 1
fi
package_version="$(node -p "require('./package.json').version")"
if [[ ! "${package_version}" =~ ^([0-9]{4})\.([1-9]|1[0-2])\.([1-9][0-9]*)$ ]] || (( 10#${BASH_REMATCH[3]:-0} < 33 )); then
echo "Extended-stable plugin publication requires a final YYYY.M.PATCH version with PATCH >= 33." >&2
exit 1
fi
release_year="${BASH_REMATCH[1]}"
release_month="${BASH_REMATCH[2]}"
extended_stable_branch="extended-stable/${release_year}.${release_month}.33"
git fetch --no-tags origin "+refs/heads/${extended_stable_branch}:refs/remotes/origin/${extended_stable_branch}"
if [[ "${WORKFLOW_REF}" == "refs/heads/${extended_stable_branch}" ]] && [[ "$(git rev-parse HEAD)" == "$(git rev-parse "refs/remotes/origin/${extended_stable_branch}")" ]]; then
exit 0
fi
echo "Extended-stable plugin npm publishes must run from ${extended_stable_branch} at its exact branch tip." >&2
exit 1
fi
git fetch --no-tags origin \
+refs/heads/main:refs/remotes/origin/main \
'+refs/heads/release/*:refs/remotes/origin/release/*'
@ -105,6 +144,7 @@ jobs:
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
HEAD_REF: ${{ steps.ref.outputs.sha }}
NPM_DIST_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag || 'default' }}
run: |
set -euo pipefail
if [[ -n "${PUBLISH_SCOPE}" ]]; then
@ -112,6 +152,9 @@ jobs:
if [[ -n "${RELEASE_PLUGINS}" ]]; then
release_args+=(--plugins "${RELEASE_PLUGINS}")
fi
if [[ "${NPM_DIST_TAG}" == "extended-stable" ]]; then
release_args+=(--npm-dist-tag "${NPM_DIST_TAG}")
fi
pnpm release:plugins:npm:check -- "${release_args[@]}"
elif [[ -n "${BASE_REF}" ]]; then
pnpm release:plugins:npm:check -- --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}"
@ -126,6 +169,7 @@ jobs:
RELEASE_PLUGINS: ${{ github.event_name == 'workflow_dispatch' && inputs.plugins || '' }}
BASE_REF: ${{ github.event_name != 'workflow_dispatch' && github.event.before || '' }}
HEAD_REF: ${{ steps.ref.outputs.sha }}
NPM_DIST_TAG: ${{ github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag || 'default' }}
run: |
set -euo pipefail
mkdir -p .local
@ -134,6 +178,9 @@ jobs:
if [[ -n "${RELEASE_PLUGINS}" ]]; then
plan_args+=(--plugins "${RELEASE_PLUGINS}")
fi
if [[ "${NPM_DIST_TAG}" == "extended-stable" ]]; then
plan_args+=(--npm-dist-tag "${NPM_DIST_TAG}")
fi
node --import tsx scripts/plugin-npm-release-plan.ts "${plan_args[@]}" > .local/plugin-npm-release-plan.json
elif [[ -n "${BASE_REF}" ]]; then
node --import tsx scripts/plugin-npm-release-plan.ts --base-ref "${BASE_REF}" --head-ref "${HEAD_REF}" > .local/plugin-npm-release-plan.json
@ -149,11 +196,13 @@ jobs:
has_candidates="true"
fi
matrix_json="$(jq -c '.candidates' .local/plugin-npm-release-plan.json)"
all_matrix_json="$(jq -c '.all' .local/plugin-npm-release-plan.json)"
{
echo "candidate_count=${candidate_count}"
echo "has_candidates=${has_candidates}"
echo "matrix=${matrix_json}"
echo "all_matrix=${all_matrix_json}"
} >> "$GITHUB_OUTPUT"
echo "Plugin release candidates:"
@ -237,9 +286,13 @@ jobs:
install-bun: "false"
- name: Preview publish command
env:
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}
run: bash scripts/plugin-npm-publish.sh --dry-run "${{ matrix.plugin.packageDir }}"
- name: Preview npm pack contents
env:
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
publish_plugins_npm:
@ -286,9 +339,10 @@ jobs:
- name: Publish
if: steps.npm_package_version.outputs.already_published != 'true'
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }}
NPM_TOKEN: ${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }}
OPENCLAW_NPM_PUBLISH_AUTH_MODE: trusted-publisher
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: ${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}
run: bash scripts/plugin-npm-publish.sh --publish "${{ matrix.plugin.packageDir }}"
- name: Verify published runtime
@ -296,3 +350,50 @@ jobs:
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
run: node scripts/verify-plugin-npm-published-runtime.mjs "${PACKAGE_NAME}@${PACKAGE_VERSION}"
verify_plugins_npm:
needs: [preview_plugins_npm, publish_plugins_npm]
if: ${{ always() && github.event_name == 'workflow_dispatch' && inputs.npm_dist_tag == 'extended-stable' && needs.preview_plugins_npm.result == 'success' && (needs.publish_plugins_npm.result == 'success' || (needs.preview_plugins_npm.outputs.has_candidates == 'false' && needs.publish_plugins_npm.result == 'skipped')) }}
runs-on: ubuntu-latest
permissions:
contents: read
strategy:
fail-fast: false
matrix:
plugin: ${{ fromJson(needs.preview_plugins_npm.outputs.all_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ needs.preview_plugins_npm.outputs.ref_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "false"
- name: Verify complete plugin registry readback
env:
PACKAGE_NAME: ${{ matrix.plugin.packageName }}
PACKAGE_VERSION: ${{ matrix.plugin.version }}
run: |
set -euo pipefail
exact_version=missing
tagged_version=missing
for attempt in {1..12}; do
exact_version="$(npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version 2>/dev/null || true)"
tagged_version="$(npm view "${PACKAGE_NAME}@extended-stable" version 2>/dev/null || true)"
if [[ "${exact_version}" == "${PACKAGE_VERSION}" && "${tagged_version}" == "${PACKAGE_VERSION}" ]]; then
echo "Verified ${PACKAGE_NAME}@${PACKAGE_VERSION} and @extended-stable."
exit 0
fi
if [[ "${attempt}" != "12" ]]; then
sleep 10
fi
done
echo "npm registry did not converge for ${PACKAGE_NAME}@${PACKAGE_VERSION} (exact=${exact_version:-missing}, extended-stable=${tagged_version:-missing})." >&2
echo "This OIDC-only source workflow does not mutate tags on an already-published package; use credential-isolated release tooling for manual tag repair." >&2
exit 1

View file

@ -159,7 +159,10 @@ remains a supported fallback and direct-install path. OpenClaw-owned
on [npmjs.com/org/openclaw](https://www.npmjs.com/org/openclaw) or the
[plugin inventory](/plugins/plugin-inventory). Stable installs use `latest`.
Beta-channel installs and updates prefer the npm `beta` dist-tag when available,
falling back to `latest`.
falling back to `latest`. On the extended-stable channel, official npm plugins
with bare/default or `latest` intent resolve to the exact installed core
version. Exact pins and explicit non-`latest` tags, third-party packages, and
non-npm sources are not rewritten.
</Note>
<AccordionGroup>

View file

@ -102,14 +102,14 @@ openclaw update repair --acknowledge-clawhub-risk
openclaw update repair --json
```
| Flag | Description |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--channel <stable\|extended-stable\|beta\|dev>` | Persist the core update channel before repair. For extended-stable, plugin convergence temporarily targets the stable/latest plugin line. Extended-stable repair is rejected on Git checkouts without changing config. |
| `--json` | Print machine-readable finalization JSON. |
| `--timeout <seconds>` | Timeout for repair steps. Default `1800`. |
| `--yes` | Skip confirmation prompts. |
| `--acknowledge-clawhub-risk` | Same behavior as on `openclaw update`. |
| `--no-restart` | Accepted for parity; repair never restarts the Gateway. |
| Flag | Description |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--channel <stable\|extended-stable\|beta\|dev>` | Persist the core update channel before repair. For extended-stable, eligible official npm plugins that follow bare/default or `latest` intent target the exact installed core version. Extended-stable repair is rejected on Git checkouts without changing config. |
| `--json` | Print machine-readable finalization JSON. |
| `--timeout <seconds>` | Timeout for repair steps. Default `1800`. |
| `--yes` | Skip confirmation prompts. |
| `--acknowledge-clawhub-risk` | Same behavior as on `openclaw update`. |
| `--no-restart` | Accepted for parity; repair never restarts the Gateway. |
`update repair` runs `openclaw doctor --fix`, reloads the repaired config and
install records, syncs tracked plugins for the active update channel, updates
@ -280,9 +280,11 @@ When the updated Gateway starts, plugin loading is verify-only: startup does not
</Note>
After an extended-stable core update succeeds, post-core plugin integrity and
convergence still run, but official plugins temporarily target the
stable/latest line. OpenClaw does not query plugin `@extended-stable`
selectors in this release.
convergence target eligible official npm plugins at the exact installed core
version. For default/`latest` intent, OpenClaw does not query plugin
`@extended-stable` or fall back to npm `latest`; it derives the package version
from the installed core. Explicit version pins, explicit non-`latest` tags,
third-party packages, and non-npm sources keep their existing intent.
For package-manager installs, `openclaw update` resolves the target package
version before invoking the package manager. npm global installs use a staged

View file

@ -116,9 +116,9 @@ Switching channels with `openclaw update` also syncs plugin sources:
their bundled (git checkout) source.
- `stable` and `beta` restore npm-installed or ClawHub-installed plugin
packages.
- `extended-stable` currently uses the existing stable/latest plugin line
after the core package succeeds. Official plugin `@extended-stable`
selectors are not queried yet.
- `extended-stable` resolves eligible official npm plugins with bare/default
or `latest` intent to the exact installed core version. It does not query
plugin `@extended-stable` tags at runtime.
- npm-installed plugins are updated after the core update completes.
## Checking current status

View file

@ -39,6 +39,14 @@ the public npm `extended-stable` selector, verifies the selected exact package,
and installs that exact version. Missing or inconsistent registry data fails
closed; it never falls back to `latest`. If the selected version is older than
the installed version, the normal downgrade confirmation still applies.
After the core swap, eligible official npm plugins with bare/default or
`latest` intent converge to that exact core version. Exact pins and explicit
non-`latest` tags, third-party plugins, and non-npm sources remain unchanged.
Catalog installs created by current OpenClaw versions retain that default
intent. Older records that contain only an exact version remain pinned because
OpenClaw cannot safely distinguish an old automatic pin from a user pin; run
`openclaw plugins update @openclaw/name` once on the extended-stable channel
to opt that plugin back into exact-core tracking.
`--channel dev` gives a persistent moving GitHub `main` checkout. For a one-off
package update, `--tag main` maps to the `github:openclaw/openclaw#main` package

View file

@ -34,7 +34,7 @@ Tideclaw alpha builds are a separate internal prerelease track (npm dist-tag `al
- `latest` continues to follow the current regular/daily npm line; `beta` is the current beta install target
- `extended-stable` means the supported trailing-month npm package, beginning at patch `33`; patch `34` and later are maintenance releases on that monthly line
- Regular final and regular correction releases publish to npm `beta` by default; release operators can target `latest` explicitly, or promote a vetted beta build later
- The dedicated monthly extended-stable path publishes only the core npm package. It does not publish plugins, macOS or Windows artifacts, a GitHub Release, private-repository dist-tags, Docker images, mobile artifacts, or website downloads.
- The dedicated monthly extended-stable path publishes the core npm package and every npm-publishable official plugin at the same exact version. It does not publish plugins to ClawHub or publish macOS or Windows artifacts, a GitHub Release, private-repository dist-tags, Docker images, mobile artifacts, or website downloads.
- Every regular final release ships the npm package, macOS app, and signed Windows Hub installers together. Beta releases normally validate and publish the npm/package path first, with native app build/sign/notarize/promote reserved for regular final unless explicitly requested.
## Release cadence
@ -55,8 +55,15 @@ already contain a strictly later calendar month's final version below patch
`33`; maintenance patches stay eligible after `main` advances by more than one
month.
Run the npm preflight and Full Release Validation from the exact
extended-stable branch, then save both run IDs:
On the exact extended-stable branch, bump the root package to `YYYY.M.P`, run
`pnpm release:prep`, and verify every publishable extension package has the
same version. Commit and push all generated changes, create and push the
immutable `vYYYY.M.P` tag at that commit, and record the resulting full SHA.
The workflows consume this prepared tree; they do not bump or synchronize
versions for you.
Run the npm preflight and Full Release Validation from that exact prepared
branch tip, then save both run IDs:
```bash
gh workflow run openclaw-npm-release.yml \
@ -75,8 +82,31 @@ gh workflow run full-release-validation.yml \
separate from the npm `extended-stable` dist-tag and is intentionally
unchanged.
After both runs succeed and the npm release environment is ready, promote the
exact preflight tarball. Patch `P` must be `33` or greater:
After both runs succeed, publish every npm-publishable official plugin from the
same exact branch tip. Patch `P` must be `33` or greater. Pass the full release
SHA as `ref`, wait for the complete matrix and registry readback, then save the
successful Plugin NPM Release run ID:
```bash
RELEASE_SHA="$(git rev-parse HEAD)"
gh workflow run plugin-npm-release.yml \
--ref extended-stable/YYYY.M.33 \
-f publish_scope=all-publishable \
-f ref="$RELEASE_SHA" \
-f npm_dist_tag=extended-stable
```
The workflow uses the regular prepared `all-publishable` package inventory,
including packages whose source did not change. It verifies every exact package
and every plugin `extended-stable` tag before succeeding. If a partial run
fails, rerun the same command: already-published packages are reused, missing
or stale plugin tags are reconciled under the npm release environment, and the
final readback still covers the complete package set.
After the plugin workflow succeeds and the npm release environment is ready,
publish the exact core preflight tarball. Core publication verifies that the
referenced plugin run is `completed/success` on the same canonical branch and
exact source SHA:
```bash
gh workflow run openclaw-npm-release.yml \
@ -85,7 +115,8 @@ gh workflow run openclaw-npm-release.yml \
-f preflight_only=false \
-f npm_dist_tag=extended-stable \
-f preflight_run_id=<npm-preflight-run-id> \
-f full_release_validation_run_id=<full-validation-run-id>
-f full_release_validation_run_id=<full-validation-run-id> \
-f plugin_npm_run_id=<plugin-npm-run-id>
```
For a fork or non-production rehearsal that intentionally cannot satisfy the
@ -98,9 +129,9 @@ branch-tip/tag/checkout equality, final-tag syntax, package/tag version
equality, referenced run and manifest identity, tarball provenance,
environment approval, registry readback, or selector repair evidence.
The publish workflow verifies the referenced run identities, the prepared
tarball digest, and both npm registry selectors. Independently confirm the
result after the workflow succeeds:
The publish workflow verifies the referenced preflight, validation, and plugin
run identities, the prepared tarball digest, and the core registry selectors.
Independently confirm the result after the workflow succeeds:
```bash
npm view openclaw@YYYY.M.P version --userconfig "$(mktemp)"
@ -114,6 +145,11 @@ printed in the failed workflow's always-run summary, then repeat both
independent readbacks. Rollback to the prior selector is a separate operator
decision, not the readback repair path.
Public support documentation initially designates Slack, Discord, and Codex as
covered extended-stable plugin surfaces. That list is a support statement, not
a release-code allowlist: every npm-publishable official plugin follows the
same exact-version publication path.
The regular checklist below continues to own beta, `latest`, GitHub Release,
plugins, macOS, Windows, and other platform publication. Do not run those
steps for this npm-only extended-stable path.
@ -461,9 +497,21 @@ Use the lower-level `Plugin NPM Release` and `Plugin ClawHub Release` workflows
- `preflight_run_id`: existing successful preflight run id, required on the real publish path so the workflow reuses the prepared tarball instead of rebuilding it
- `full_release_validation_run_id`: successful `Full Release Validation` run id for this tag/SHA, required for real publish. Beta publishes may proceed on preflight alone with a warning, but stable/`latest` promotion still requires it.
- `release_publish_run_id`: approved `OpenClaw Release Publish` run id; required when this workflow is dispatched by that parent (bot-actor real-publish calls)
- `plugin_npm_run_id`: successful exact-head `Plugin NPM Release` run id; required for a real `extended-stable` core publish
- `npm_dist_tag`: npm target tag for the publish path; accepts `alpha`, `beta`, `latest`, or `extended-stable` and defaults to `beta`. Final patch `33` and later must use `extended-stable`; by default, `extended-stable` rejects earlier patches, and it always rejects non-final tags.
- `bypass_extended_stable_guard`: testing-only boolean, default `false`; with `npm_dist_tag=extended-stable`, bypasses monthly extended-stable eligibility while preserving release identity, artifact, approval, and readback checks.
`Plugin NPM Release` accepts `npm_dist_tag=default` for existing release
behavior or `npm_dist_tag=extended-stable` for the guarded monthly path. The
extended-stable option requires `publish_scope=all-publishable`, an empty
`plugins` input, a final patch at or above `33`, and the canonical
`extended-stable/YYYY.M.33` branch at its exact tip. It never moves plugin
`latest` or `beta`. New package versions receive `extended-stable` atomically
through OIDC trusted publication (`npm publish --tag extended-stable`); this
source workflow does not use token-authenticated `npm dist-tag add`. Retries
skip exact versions already present in npm, then fail closed unless complete
readback confirms that every exact package and `extended-stable` tag converged.
`OpenClaw Release Publish` accepts these operator-controlled inputs:
- `tag`: required release tag; must already exist

View file

@ -24,7 +24,7 @@ export const JUNE_2026_PATCH_FLOOR = 5;
/**
* @typedef {object} NpmPublishPlan
* @property {"stable" | "alpha" | "beta"} channel
* @property {"latest" | "alpha" | "beta"} publishTag
* @property {"latest" | "alpha" | "beta" | "extended-stable"} publishTag
* @property {("latest" | "alpha" | "beta")[]} mirrorDistTags
*/
@ -199,14 +199,38 @@ export function compareReleaseVersions(left, right) {
/**
* @param {string} version
* @param {string | null} [currentBetaVersion]
* @param {string | null} [publishTagOverride]
* @returns {NpmPublishPlan}
*/
export function resolveNpmPublishPlan(version, currentBetaVersion) {
export function resolveNpmPublishPlan(version, currentBetaVersion, publishTagOverride) {
const parsedVersion = parseReleaseVersion(version);
if (parsedVersion === null) {
throw new Error(`Unsupported release version "${version}".`);
}
const normalizedOverride = publishTagOverride?.trim();
if (normalizedOverride && normalizedOverride !== "extended-stable") {
throw new Error(
`Unsupported npm publish tag override "${normalizedOverride}". Expected "extended-stable".`,
);
}
if (normalizedOverride === "extended-stable") {
if (
parsedVersion.channel !== "stable" ||
parsedVersion.correctionNumber !== undefined ||
parsedVersion.patch < 33
) {
throw new Error(
`Extended-stable npm publication requires a final YYYY.M.PATCH version with PATCH >= 33; found "${version}".`,
);
}
return {
channel: "stable",
publishTag: "extended-stable",
mirrorDistTags: [],
};
}
if (parsedVersion.channel === "beta") {
return {
channel: "beta",

View file

@ -54,7 +54,7 @@ export type PublishablePluginPackage = {
packageName: string;
version: string;
channel: "stable" | "alpha" | "beta";
publishTag: "latest" | "alpha" | "beta";
publishTag: "latest" | "alpha" | "beta" | "extended-stable";
installNpmSpec?: string;
requiredLatestDependencies?: RequiredLatestDependency[];
};
@ -84,6 +84,20 @@ export type ParsedPluginReleaseArgs = {
headRef?: string;
};
type ParsedPluginNpmReleaseArgs = ParsedPluginReleaseArgs & {
npmDistTag?: "extended-stable";
};
function parsePluginNpmDistTagOverride(value: string | undefined): "extended-stable" | undefined {
if (value === undefined || value.trim() === "") {
return undefined;
}
if (value === "extended-stable") {
return value;
}
throw new Error(`Unknown npm dist-tag override: ${value}. Expected "extended-stable".`);
}
export type PublishablePluginPackageCandidate<
TPackageJson extends PluginPackageJson = PluginPackageJson,
> = {
@ -288,10 +302,33 @@ export function parsePluginReleaseArgs(argv: string[]): ParsedPluginReleaseArgs
if ((baseRef && !headRef) || (!baseRef && headRef)) {
throw new Error("Both --base-ref and --head-ref are required together.");
}
return { selection, selectionMode, pluginsFlagProvided, baseRef, headRef };
}
export function parsePluginNpmReleaseArgs(argv: string[]): ParsedPluginNpmReleaseArgs {
const baseArgs: string[] = [];
let npmDistTag: "extended-stable" | undefined;
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg !== "--npm-dist-tag") {
baseArgs.push(arg);
continue;
}
if (npmDistTag !== undefined) {
throw new Error("--npm-dist-tag must not be provided more than once.");
}
npmDistTag = parsePluginNpmDistTagOverride(readRequiredArgValue(argv, index, arg));
index += 1;
}
const parsed = parsePluginReleaseArgs(baseArgs);
if (npmDistTag === "extended-stable" && parsed.selectionMode !== "all-publishable") {
throw new Error(
"extended-stable requires --selection-mode all-publishable without an explicit plugin list.",
);
}
return { ...parsed, npmDistTag };
}
function readRequiredArgValue(
argv: string[],
index: number,
@ -368,6 +405,7 @@ export function collectPublishablePluginPackageErrors(
export type PublishablePluginPackageFilters = {
extensionIds?: readonly string[];
packageNames?: readonly string[];
npmDistTag?: "extended-stable";
};
export function collectPublishablePluginPackages(
@ -417,12 +455,24 @@ export function collectPublishablePluginPackages(
packageName,
version,
channel: parsedVersion.channel,
publishTag: resolveNpmPublishPlan(version).publishTag,
publishTag: resolveNpmPublishPlan(version, undefined, filters.npmDistTag).publishTag,
installNpmSpec: normalizeOptionalString(packageJson.openclaw?.install?.npmSpec),
...(requiredLatestDependencies.length > 0 ? { requiredLatestDependencies } : {}),
});
}
if (filters.npmDistTag === "extended-stable") {
const rootPackage = readPluginPackageJson(join(rootDir, "package.json")) as PluginPackageJson;
const rootVersion = rootPackage.version?.trim() ?? "";
for (const plugin of publishable) {
if (plugin.version !== rootVersion) {
validationErrors.push(
`${plugin.extensionId}: package version ${plugin.version} must match root package version ${rootVersion || "<missing>"} for extended-stable publication.`,
);
}
}
}
if (validationErrors.length > 0) {
throw new Error(
`Publishable plugin metadata validation failed:\n${validationErrors.map((error) => `- ${error}`).join("\n")}`,
@ -677,6 +727,7 @@ export function collectPluginReleasePlan(params?: {
selection?: string[];
selectionMode?: PluginReleaseSelectionMode;
gitRange?: GitRangeSelection;
npmDistTag?: "extended-stable";
}): PluginReleasePlan {
const changedExtensionIds = params?.gitRange
? collectChangedExtensionIdsFromGitRange({
@ -690,6 +741,7 @@ export function collectPluginReleasePlan(params?: {
? undefined
: changedExtensionIds,
packageNames: params?.selection && params.selection.length > 0 ? params.selection : undefined,
npmDistTag: params?.npmDistTag,
});
const selectedPublishable =
params?.selectionMode === "all-publishable"

View file

@ -160,12 +160,19 @@ export function validateExtendedStableRunIdentity({
expectedSha,
}) {
const expectedWorkflowName =
kind === "preflight" ? "OpenClaw NPM Release" : "Full Release Validation";
kind === "preflight"
? "OpenClaw NPM Release"
: kind === "plugin"
? "Plugin NPM Release"
: "Full Release Validation";
const checks = [
["workflowName", expectedWorkflowName],
["event", "workflow_dispatch"],
...(kind === "validation" ? [["status", "completed"]] : []),
...(kind === "validation" || kind === "plugin" ? [["status", "completed"]] : []),
["conclusion", "success"],
...(kind === "plugin" && npmDistTag === "extended-stable"
? [["displayTitle", `Plugin NPM Release [extended-stable] ${expectedSha}`]]
: []),
];
for (const [key, expected] of checks) {
if (run[key] !== expected) {

View file

@ -58,6 +58,7 @@ import {
const plan = resolveNpmPublishPlan(
process.env.PACKAGE_VERSION ?? "",
process.env.CURRENT_BETA_VERSION,
process.env.OPENCLAW_PLUGIN_NPM_PUBLISH_TAG,
);
const auth = resolveNpmDistTagMirrorAuth({
nodeAuthToken: process.env.NODE_AUTH_TOKEN,

View file

@ -7,13 +7,14 @@ import {
assertPluginReleaseVersionFloors,
collectChangedExtensionIdsFromGitRange,
collectPublishablePluginPackages,
parsePluginReleaseArgs,
parsePluginNpmReleaseArgs,
resolveChangedPublishablePluginPackages,
resolveSelectedPublishablePluginPackages,
} from "./lib/plugin-npm-release.ts";
export function runPluginNpmReleaseCheck(argv: string[]) {
const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv);
const { selection, selectionMode, npmDistTag, baseRef, headRef } =
parsePluginNpmReleaseArgs(argv);
const changedExtensionIds =
baseRef && headRef
? collectChangedExtensionIdsFromGitRange({
@ -26,6 +27,7 @@ export function runPluginNpmReleaseCheck(argv: string[]) {
? undefined
: changedExtensionIds,
packageNames: selection.length > 0 ? selection : undefined,
npmDistTag,
});
const selected =
selectionMode === "all-publishable"

View file

@ -2,13 +2,15 @@
// Plugin Npm Release Plan script supports OpenClaw repository automation.
import { pathToFileURL } from "node:url";
import { collectPluginReleasePlan, parsePluginReleaseArgs } from "./lib/plugin-npm-release.ts";
import { collectPluginReleasePlan, parsePluginNpmReleaseArgs } from "./lib/plugin-npm-release.ts";
export function collectPluginNpmReleasePlan(argv: string[]) {
const { selection, selectionMode, baseRef, headRef } = parsePluginReleaseArgs(argv);
const { selection, selectionMode, npmDistTag, baseRef, headRef } =
parsePluginNpmReleaseArgs(argv);
return collectPluginReleasePlan({
selection,
selectionMode,
npmDistTag,
gitRange: baseRef && headRef ? { baseRef, headRef } : undefined,
});
}

View file

@ -1034,6 +1034,32 @@ describe("plugins cli update", () => {
expect(updateParams.updateChannel).toBeUndefined();
});
it("passes extended-stable channel and installed core version to update --all", async () => {
const config = createTrackedPluginConfig({
pluginId: "codex",
spec: "@openclaw/codex",
resolvedName: "@openclaw/codex",
});
config.update = { channel: "extended-stable" };
loadConfig.mockReturnValue(config);
setInstalledPluginIndexInstallRecords(config.plugins?.installs ?? {});
updateNpmInstalledPlugins.mockResolvedValue({
config,
changed: false,
outcomes: [],
});
await runPluginsCommand(["plugins", "update", "--all"]);
expect(updateNpmInstalledPlugins).toHaveBeenCalledWith(
expect.objectContaining({
officialPluginUpdateChannel: "extended-stable",
syncOfficialPluginInstalls: true,
coreVersion: expect.any(String),
}),
);
});
it("passes ClawHub risk acknowledgement to plugin updates", async () => {
const config = createTrackedPluginConfig({
pluginId: "openclaw-codex-app-server",

View file

@ -24,6 +24,7 @@ import {
updateNpmInstalledPlugins,
} from "../plugins/update.js";
import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js";
import { resolveClawHubRiskAcknowledgementCliOptions } from "./clawhub-risk-acknowledgement.js";
import {
containsConfigIncludeDirective,
@ -271,6 +272,7 @@ export async function runPluginUpdateCommand(params: {
? (normalizeUpdateChannel(cfg.update?.channel) ?? undefined)
: undefined,
syncOfficialPluginInstalls: params.opts.all ? true : undefined,
coreVersion: VERSION,
dangerouslyForceUnsafeInstall: params.opts.dangerouslyForceUnsafeInstall,
...resolveClawHubRiskAcknowledgementCliOptions({
acknowledgeClawHubRisk: params.opts.acknowledgeClawHubRisk,

View file

@ -2564,6 +2564,7 @@ describe("update-cli", () => {
it("installs the verified exact package and persists an explicit extended-stable channel", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
readPackageVersion.mockResolvedValue("2026.6.33");
await updateCommand({ channel: "extended-stable", yes: true, restart: false });
@ -2574,13 +2575,16 @@ describe("update-cli", () => {
});
expectPackageInstallSpec("openclaw@2026.6.33");
expect(lastReplaceConfigCall()?.nextConfig?.update?.channel).toBe("extended-stable");
expect(syncPluginCall()?.channel).toBe("stable");
expect(lastNpmPluginUpdateCall()?.updateChannel).toBe("stable");
expect(syncPluginCall()?.channel).toBe("extended-stable");
expect(syncPluginCall()?.coreVersion).toBe("2026.6.33");
expect(lastNpmPluginUpdateCall()?.updateChannel).toBe("extended-stable");
expect(lastNpmPluginUpdateCall()?.coreVersion).toBe("2026.6.33");
});
it("uses the same exact resolver for a bare update with stored extended-stable", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
readPackageVersion.mockResolvedValue("2026.6.33");
const config = { update: { channel: "extended-stable" } } as OpenClawConfig;
vi.mocked(readConfigFileSnapshot).mockResolvedValue({
...baseSnapshot,
@ -2599,7 +2603,8 @@ describe("update-cli", () => {
packageName: "openclaw",
});
expectPackageInstallSpec("openclaw@2026.6.33");
expect(syncPluginCall()?.channel).toBe("stable");
expect(syncPluginCall()?.channel).toBe("extended-stable");
expect(syncPluginCall()?.coreVersion).toBe("2026.6.33");
});
it("fails closed without config or package mutation when extended-stable resolution fails", async () => {

View file

@ -1955,8 +1955,8 @@ export async function updatePluginsAfterCoreUpdate(params: {
);
const pluginInstallRecords =
params.pluginInstallRecords ?? (await loadInstalledPluginIndexInstallRecords());
const pluginUpdateChannel: UpdateChannel =
params.channel === "extended-stable" ? "stable" : params.channel;
const pluginUpdateChannel = params.channel;
const coreVersion = await readPackageVersion(params.root);
const syncConfig = withPluginInstallRecords(
params.configSnapshot.sourceConfig,
pluginInstallRecords,
@ -1964,6 +1964,7 @@ export async function updatePluginsAfterCoreUpdate(params: {
const syncResult = await syncPluginsForUpdateChannel({
config: syncConfig,
channel: pluginUpdateChannel,
coreVersion: coreVersion ?? undefined,
workspaceDir: params.root,
externalizedBundledPluginBridges: await listPersistedBundledPluginLocationBridges({
workspaceDir: params.root,
@ -2037,6 +2038,7 @@ export async function updatePluginsAfterCoreUpdate(params: {
pluginIds: missingIds,
timeoutMs: params.timeoutMs,
updateChannel: pluginUpdateChannel,
coreVersion: coreVersion ?? undefined,
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,
disableOnFailure: true,
@ -2057,6 +2059,7 @@ export async function updatePluginsAfterCoreUpdate(params: {
config: pluginConfig,
timeoutMs: params.timeoutMs,
updateChannel: pluginUpdateChannel,
coreVersion: coreVersion ?? undefined,
skipIds: new Set([...syncResult.summary.switchedToNpm, ...missingPayloadIdSet]),
skipDisabledPlugins: true,
syncOfficialPluginInstalls: true,

View file

@ -956,6 +956,53 @@ describe("repairMissingConfiguredPluginInstalls", () => {
]);
});
it("repairs official plugins at the exact extended-stable core version", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "diagnostics-otel",
targetDir: "/tmp/openclaw-plugins/diagnostics-otel",
version: VERSION,
npmResolution: {
name: "@openclaw/diagnostics-otel",
version: VERSION,
resolvedSpec: `@openclaw/diagnostics-otel@${VERSION}`,
},
});
mocks.listOfficialExternalPluginCatalogEntries.mockReturnValue([
{
id: "diagnostics-otel",
label: "Diagnostics OpenTelemetry",
install: {
npmSpec: "@openclaw/diagnostics-otel",
defaultChoice: "npm",
},
},
]);
const { repairMissingConfiguredPluginInstalls } =
await import("./missing-configured-plugin-install.js");
await repairMissingConfiguredPluginInstalls({
cfg: {
update: { channel: "extended-stable" },
plugins: { entries: { "diagnostics-otel": { enabled: true } } },
},
env: {},
});
expectRecordFields(mockCallArg(mocks.installPluginFromNpmSpec), {
spec: `@openclaw/diagnostics-otel@${VERSION}`,
expectedPluginId: "diagnostics-otel",
trustedSourceLinkedOfficialInstall: true,
});
const persistedRecords = mockCallArg(
mocks.writePersistedInstalledPluginIndexInstallRecords,
) as Record<string, unknown>;
expectRecordFields(persistedRecords["diagnostics-otel"], {
spec: "@openclaw/diagnostics-otel",
resolvedSpec: `@openclaw/diagnostics-otel@${VERSION}`,
});
});
it("installs the official llama.cpp plugin for configured local memory embeddings", async () => {
mocks.installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
@ -2231,7 +2278,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
const records = mockCallArg(mocks.writePersistedInstalledPluginIndexInstallRecords);
expectRecordFields((records as Record<string, unknown>).codex, {
source: "npm",
spec: "@openclaw/codex@2026.5.2",
spec: "@openclaw/codex",
installPath: "/tmp/openclaw-plugins/codex",
version: "2026.5.2",
});
@ -2332,7 +2379,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
]);
expectRecordFields(result.records.codex, {
source: "npm",
spec: `@openclaw/codex@${VERSION}`,
spec: "@openclaw/codex",
installPath: "/tmp/openclaw-plugins/codex",
version: VERSION,
resolvedName: "@openclaw/codex",
@ -2431,7 +2478,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
]);
expectRecordFields(firstPass.records.codex, {
source: "npm",
spec: `@openclaw/codex@${codexBetaVersion}`,
spec: "@openclaw/codex",
installPath: installDir,
version: codexBetaVersion,
resolvedName: "@openclaw/codex",
@ -2642,7 +2689,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
const records = mockCallArg(mocks.writePersistedInstalledPluginIndexInstallRecords);
expectRecordFields((records as Record<string, unknown>).codex, {
source: "npm",
spec: "@openclaw/codex@2026.5.2",
spec: "@openclaw/codex",
installPath: "/tmp/openclaw-plugins/codex",
version: "2026.5.2",
});
@ -2656,7 +2703,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
expect(Object.keys(result.records)).toEqual(["codex"]);
expectRecordFields(result.records.codex, {
source: "npm",
spec: "@openclaw/codex@2026.5.2",
spec: "@openclaw/codex",
installPath: "/tmp/openclaw-plugins/codex",
version: "2026.5.2",
resolvedName: "@openclaw/codex",
@ -3422,7 +3469,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
});
const persistedRecords = mockCallArg(mocks.writePersistedInstalledPluginIndexInstallRecords);
expectRecordFields((persistedRecords as Record<string, unknown>).discord, {
spec: "@openclaw/discord@1.2.3",
spec: "@openclaw/discord",
installPath: "/tmp/openclaw-plugins/discord",
});
expect(mockCallArg(mocks.writePersistedInstalledPluginIndexInstallRecords, 0, 1)).toEqual({
@ -3787,7 +3834,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
) as Record<string, unknown>;
expectRecordFields(persistedRecords.brave, {
source: "npm",
spec: "@openclaw/brave-plugin@2026.5.12",
spec: "@openclaw/brave-plugin",
installPath: "/tmp/openclaw-plugins/brave",
version: "2026.5.12",
});
@ -4616,7 +4663,7 @@ describe("repairMissingConfiguredPluginInstalls", () => {
mocks.writePersistedInstalledPluginIndexInstallRecords,
) as Record<string, unknown>;
expectRecordFields(persistedRecords.brave, {
spec: "@openclaw/brave-plugin@2026.5.4-beta.1",
spec: "@openclaw/brave-plugin",
});
expect(mockCallArg(mocks.writePersistedInstalledPluginIndexInstallRecords, 0, 1)).toEqual({
env: {},

View file

@ -73,7 +73,7 @@ import {
resolveWebSearchInstallCatalogEntry,
} from "../../../plugins/web-search-install-catalog.js";
import { resolveUserPath } from "../../../utils.js";
import { VERSION } from "../../../version.js";
import { resolveCompatibilityHostVersion, VERSION } from "../../../version.js";
import { collectConfiguredProviderPluginIds } from "./configured-provider-plugin-installs.js";
import {
collectConfiguredRuntimePluginIds,
@ -1071,6 +1071,10 @@ async function installCandidate(params: {
? resolveNpmInstallSpecsForUpdateChannel({
spec: candidate.npmSpec,
updateChannel: params.updateChannel,
officialPackageName: candidate.trustedSourceLinkedOfficialInstall
? parseRegistryNpmSpec(candidate.npmSpec)?.name
: undefined,
coreVersion: resolveCompatibilityHostVersion(params.env),
})
: null;
const clawhubInstallSpec = clawhubSpecs?.installSpec ?? candidate.clawhubSpec;
@ -1100,6 +1104,7 @@ async function installCandidate(params: {
records: params.records,
npmInstallSpec,
npmRecordSpec: npmSpecs?.recordSpec ?? npmInstallSpec,
pinResolvedRegistrySpec: false,
packagePath: existingNpmPackagePath,
version: existingNpmPackageVersion,
});
@ -1229,7 +1234,7 @@ async function installCandidate(params: {
spec: resolveNpmInstallRecordSpec({
requestedSpec: npmSpecs?.recordSpec ?? npmInstallSpec,
resolution: result.npmResolution,
pinResolvedRegistrySpec: candidate.trustedSourceLinkedOfficialInstall === true,
pinResolvedRegistrySpec: false,
}),
installPath: result.targetDir,
version: result.version,
@ -1308,6 +1313,7 @@ async function adoptExistingNpmPackage(params: {
records: Record<string, PluginInstallRecord>;
npmInstallSpec: string;
npmRecordSpec: string;
pinResolvedRegistrySpec: boolean;
packagePath: string;
version: string;
}): Promise<{
@ -1332,7 +1338,7 @@ async function adoptExistingNpmPackage(params: {
spec: resolveNpmInstallRecordSpec({
requestedSpec: params.npmRecordSpec,
resolution: npmResolution,
pinResolvedRegistrySpec: params.candidate.trustedSourceLinkedOfficialInstall === true,
pinResolvedRegistrySpec: params.pinResolvedRegistrySpec,
}),
installPath: params.packagePath,
installedAt: new Date().toISOString(),
@ -1353,6 +1359,7 @@ async function adoptExistingNpmPackage(params: {
function resolveCandidateInstallSpec(params: {
candidate: DownloadableInstallCandidate;
updateChannel: UpdateChannel;
coreVersion: string;
}): string | undefined {
if (params.candidate.defaultChoice !== "npm" && params.candidate.clawhubSpec) {
return resolveClawHubInstallSpecsForUpdateChannel({
@ -1364,6 +1371,10 @@ function resolveCandidateInstallSpec(params: {
return resolveNpmInstallSpecsForUpdateChannel({
spec: params.candidate.npmSpec,
updateChannel: params.updateChannel,
officialPackageName: params.candidate.trustedSourceLinkedOfficialInstall
? parseRegistryNpmSpec(params.candidate.npmSpec)?.name
: undefined,
coreVersion: params.coreVersion,
}).installSpec;
}
if (params.candidate.clawhubSpec) {
@ -1608,7 +1619,11 @@ export async function detectConfiguredPluginInstallHealthIssues(params: {
if (!shouldReplaceBrokenOfficialInstall && hasUsableRecord) {
continue;
}
const installSpec = resolveCandidateInstallSpec({ candidate, updateChannel });
const installSpec = resolveCandidateInstallSpec({
candidate,
updateChannel,
coreVersion: resolveCompatibilityHostVersion(env),
});
if (shouldReplaceBrokenOfficialInstall) {
const installPath = resolveRecordInstallPath(record, env);
if (staleVersionBoundRuntimePluginIds.has(candidate.pluginId)) {
@ -2005,6 +2020,7 @@ async function repairMissingPluginInstalls(params: {
},
pluginIds: missingRecordedPluginIds,
updateChannel,
coreVersion: resolveCompatibilityHostVersion(env),
logger: {
terminalLinks: false,
warn: (message) => {

View file

@ -734,6 +734,88 @@ describe("ensureOnboardingPluginInstalled", () => {
);
});
it("installs trusted official plugins at the exact extended-stable core version", async () => {
installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "discord",
targetDir: "/tmp/discord",
version: VERSION,
npmResolution: {
name: "@openclaw/discord",
version: VERSION,
resolvedSpec: `@openclaw/discord@${VERSION}`,
},
});
await ensureOnboardingPluginInstalled({
cfg: { update: { channel: "extended-stable" } },
entry: {
pluginId: "discord",
label: "Discord",
install: { npmSpec: "@openclaw/discord" },
trustedSourceLinkedOfficialInstall: true,
},
prompter: {
select: vi.fn(async () => "npm"),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
} as never,
runtime: {} as never,
promptInstall: false,
});
const [npmCall] = readFirstMockCall(installPluginFromNpmSpec, "installPluginFromNpmSpec") as [
NpmSpecInstallCall,
];
expect(npmCall.spec).toBe(`@openclaw/discord@${VERSION}`);
const [, recordUpdate] = readFirstMockCall(recordPluginInstall, "recordPluginInstall") as [
OpenClawConfig,
PluginInstallRecord,
];
expect(recordUpdate.spec).toBe("@openclaw/discord");
expect(resolveNpmInstallRecordSpec).toHaveBeenCalledWith(
expect.objectContaining({ pinResolvedRegistrySpec: false }),
);
});
it("preserves default intent for trusted official stable installs", async () => {
installPluginFromNpmSpec.mockResolvedValueOnce({
ok: true,
pluginId: "discord",
targetDir: "/tmp/discord",
version: "2026.7.21",
npmResolution: {
name: "@openclaw/discord",
version: "2026.7.21",
resolvedSpec: "@openclaw/discord@2026.7.21",
},
});
await ensureOnboardingPluginInstalled({
cfg: { update: { channel: "stable" } },
entry: {
pluginId: "discord",
label: "Discord",
install: { npmSpec: "@openclaw/discord" },
trustedSourceLinkedOfficialInstall: true,
},
prompter: {
select: vi.fn(async () => "npm"),
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
} as never,
runtime: {} as never,
promptInstall: false,
});
const [, recordUpdate] = readFirstMockCall(recordPluginInstall, "recordPluginInstall") as [
OpenClawConfig,
PluginInstallRecord,
];
expect(recordUpdate.spec).toBe("@openclaw/discord");
expect(resolveNpmInstallRecordSpec).toHaveBeenCalledWith(
expect.objectContaining({ pinResolvedRegistrySpec: false }),
);
});
it("logs npm install warnings once while shortening the progress label", async () => {
const warning =
"npm rejected managed npm alias overrides; retrying plugin install without alias overrides for this npm version.";

View file

@ -381,7 +381,11 @@ function resolveInstallDefaultChoice(params: {
if (updateChannel === "dev") {
return "local";
}
if (updateChannel === "stable" || updateChannel === "beta") {
if (
updateChannel === "stable" ||
updateChannel === "extended-stable" ||
updateChannel === "beta"
) {
return remoteDefault();
}
if (entryDefault === "local") {
@ -1137,6 +1141,10 @@ export async function ensureOnboardingPluginInstalled(params: {
? resolveNpmInstallSpecsForUpdateChannel({
spec: npmSpec,
updateChannel,
officialPackageName: entry.trustedSourceLinkedOfficialInstall
? parseRegistryNpmSpec(npmSpec)?.name
: undefined,
coreVersion: VERSION,
})
: null;
const clawhubInstallSpec = clawhubSpecs?.installSpec ?? clawhubSpec;
@ -1383,7 +1391,7 @@ export async function ensureOnboardingPluginInstalled(params: {
spec: resolveNpmInstallRecordSpec({
requestedSpec: npmSpecs?.recordSpec ?? npmInstallSpec,
resolution: result.npmResolution,
pinResolvedRegistrySpec: entry.trustedSourceLinkedOfficialInstall === true,
pinResolvedRegistrySpec: false,
}),
installPath: result.targetDir,
version: result.version,

View file

@ -0,0 +1,92 @@
import { describe, expect, it } from "vitest";
import {
resolveClawHubInstallSpecsForUpdateChannel,
resolveNpmInstallSpecsForUpdateChannel,
} from "./install-channel-specs.js";
describe("resolveNpmInstallSpecsForUpdateChannel", () => {
it.each(["@openclaw/discord", "@openclaw/discord@latest"])(
"targets the exact core version for official extended-stable intent %s",
(spec) => {
expect(
resolveNpmInstallSpecsForUpdateChannel({
spec,
updateChannel: "extended-stable",
officialPackageName: "@openclaw/discord",
coreVersion: "2026.7.33",
}),
).toEqual({
installSpec: "@openclaw/discord@2026.7.33",
recordSpec: spec,
});
},
);
it.each([
"@openclaw/discord@2026.6.33",
"@openclaw/discord@next",
"@openclaw/discord@beta",
"@openclaw/discord@^2026.6.0",
"https://registry.example.test/discord.tgz",
])("preserves explicit extended-stable intent %s", (spec) => {
expect(
resolveNpmInstallSpecsForUpdateChannel({
spec,
updateChannel: "extended-stable",
officialPackageName: "@openclaw/discord",
coreVersion: "2026.7.33",
}),
).toEqual({ installSpec: spec, recordSpec: spec });
});
it("does not rewrite a third-party package", () => {
expect(
resolveNpmInstallSpecsForUpdateChannel({
spec: "@acme/discord",
updateChannel: "extended-stable",
officialPackageName: "@openclaw/discord",
coreVersion: "2026.7.33",
}),
).toEqual({ installSpec: "@acme/discord", recordSpec: "@acme/discord" });
});
it("fails closed without an authoritative extended-stable core version", () => {
expect(() =>
resolveNpmInstallSpecsForUpdateChannel({
spec: "@openclaw/discord",
updateChannel: "extended-stable",
officialPackageName: "@openclaw/discord",
}),
).toThrow("requires an exact core version");
});
it("preserves beta behavior", () => {
expect(
resolveNpmInstallSpecsForUpdateChannel({
spec: "@openclaw/discord@latest",
updateChannel: "beta",
officialPackageName: "@openclaw/discord",
coreVersion: "2026.7.33",
}),
).toEqual({
installSpec: "@openclaw/discord@beta",
recordSpec: "@openclaw/discord@latest",
fallbackSpec: "@openclaw/discord@latest",
fallbackLabel: "@openclaw/discord@beta",
});
});
});
describe("resolveClawHubInstallSpecsForUpdateChannel", () => {
it("does not rewrite ClawHub on extended-stable", () => {
expect(
resolveClawHubInstallSpecsForUpdateChannel({
spec: "clawhub:@openclaw/discord",
updateChannel: "extended-stable",
}),
).toEqual({
installSpec: "clawhub:@openclaw/discord",
recordSpec: "clawhub:@openclaw/discord",
});
});
});

View file

@ -1,6 +1,6 @@
// Parses channel-oriented plugin install specs from package inputs.
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import { isExactSemverVersion, parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import type { UpdateChannel } from "../infra/update-channels.js";
export type ChannelInstallSpecs = {
@ -10,7 +10,7 @@ export type ChannelInstallSpecs = {
fallbackLabel?: string;
};
function isDefaultNpmSpecForBetaChannel(spec: string): { name: string } | null {
function resolveDefaultNpmSpec(spec: string): { name: string } | null {
const parsed = parseRegistryNpmSpec(spec);
if (!parsed) {
return null;
@ -38,14 +38,35 @@ function isDefaultClawHubSpecForBetaChannel(spec: string): { name: string } | nu
export function resolveNpmInstallSpecsForUpdateChannel(params: {
spec: string;
updateChannel?: UpdateChannel;
officialPackageName?: string;
coreVersion?: string;
}): ChannelInstallSpecs {
if (params.updateChannel === "extended-stable") {
const target = resolveDefaultNpmSpec(params.spec);
if (target && params.officialPackageName === target.name) {
const coreVersion = params.coreVersion?.trim();
if (!coreVersion || !isExactSemverVersion(coreVersion)) {
throw new Error(
`Extended-stable plugin resolution for ${target.name} requires an exact core version.`,
);
}
return {
installSpec: `${target.name}@${coreVersion}`,
recordSpec: params.spec,
};
}
return {
installSpec: params.spec,
recordSpec: params.spec,
};
}
if (params.updateChannel !== "beta") {
return {
installSpec: params.spec,
recordSpec: params.spec,
};
}
const betaTarget = isDefaultNpmSpecForBetaChannel(params.spec);
const betaTarget = resolveDefaultNpmSpec(params.spec);
if (!betaTarget) {
return {
installSpec: params.spec,

View file

@ -3220,6 +3220,138 @@ describe("updateNpmInstalledPlugins", () => {
});
});
it("targets the exact core version for official extended-stable updates and preserves intent", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/acpx",
version: "2026.7.21",
});
mockNpmViewMetadata({
name: "@openclaw/acpx",
version: "2026.7.33",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "acpx",
targetDir: installPath,
version: "2026.7.33",
npmResolution: {
name: "@openclaw/acpx",
version: "2026.7.33",
resolvedSpec: "@openclaw/acpx@2026.7.33",
},
}),
);
const result = await updateNpmInstalledPlugins({
config: createNpmInstallConfig({
pluginId: "acpx",
spec: "@openclaw/acpx",
installPath,
resolvedName: "@openclaw/acpx",
resolvedSpec: "@openclaw/acpx@2026.7.21",
resolvedVersion: "2026.7.21",
}),
pluginIds: ["acpx"],
syncOfficialPluginInstalls: true,
officialPluginUpdateChannel: "extended-stable",
coreVersion: "2026.7.33",
});
expectNpmUpdateCall({
spec: "@openclaw/acpx@2026.7.33",
expectedPluginId: "acpx",
});
expectRecordFields(result.config.plugins?.installs?.acpx, {
spec: "@openclaw/acpx",
version: "2026.7.33",
resolvedSpec: "@openclaw/acpx@2026.7.33",
});
});
it("preserves an explicit official pin during extended-stable updates", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/acpx",
version: "2026.6.33",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "acpx",
targetDir: installPath,
version: "2026.6.33",
}),
);
await updateNpmInstalledPlugins({
config: createNpmInstallConfig({
pluginId: "acpx",
spec: "@openclaw/acpx@2026.6.33",
installPath,
resolvedName: "@openclaw/acpx",
resolvedSpec: "@openclaw/acpx@2026.6.33",
resolvedVersion: "2026.6.33",
}),
pluginIds: ["acpx"],
syncOfficialPluginInstalls: true,
officialPluginUpdateChannel: "extended-stable",
coreVersion: "2026.7.33",
dryRun: true,
});
expectNpmUpdateCall({
spec: "@openclaw/acpx@2026.6.33",
expectedPluginId: "acpx",
});
});
it("lets an explicit bare official spec opt a legacy pin into exact-core tracking", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/acpx",
version: "2026.6.21",
});
mockNpmViewMetadata({
name: "@openclaw/acpx",
version: "2026.7.33",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "acpx",
targetDir: installPath,
version: "2026.7.33",
npmResolution: {
name: "@openclaw/acpx",
version: "2026.7.33",
resolvedSpec: "@openclaw/acpx@2026.7.33",
},
}),
);
const result = await updateNpmInstalledPlugins({
config: createNpmInstallConfig({
pluginId: "acpx",
spec: "@openclaw/acpx@2026.6.21",
installPath,
resolvedName: "@openclaw/acpx",
resolvedSpec: "@openclaw/acpx@2026.6.21",
resolvedVersion: "2026.6.21",
}),
pluginIds: ["acpx"],
specOverrides: { acpx: "@openclaw/acpx" },
syncOfficialPluginInstalls: true,
officialPluginUpdateChannel: "extended-stable",
coreVersion: "2026.7.33",
});
expectNpmUpdateCall({
spec: "@openclaw/acpx@2026.7.33",
expectedPluginId: "acpx",
});
expectRecordFields(result.config.plugins?.installs?.acpx, {
spec: "@openclaw/acpx",
version: "2026.7.33",
resolvedSpec: "@openclaw/acpx@2026.7.33",
});
});
it("falls back to the default npm spec when a beta tag is unavailable", async () => {
installPluginFromNpmSpecMock
.mockResolvedValueOnce({
@ -3730,6 +3862,54 @@ describe("updateNpmInstalledPlugins", () => {
]);
});
it("uses exact-core npm when an official ClawHub install falls back on extended-stable", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/discord",
version: "2026.6.33",
});
installPluginFromClawHubMock.mockResolvedValueOnce({
ok: false,
code: "artifact_unavailable",
error: "artifact unavailable",
});
installPluginFromNpmSpecMock.mockResolvedValueOnce(
createSuccessfulNpmUpdateResult({
pluginId: "discord",
targetDir: "/tmp/openclaw-plugins/discord",
version: "2026.7.33",
npmResolution: {
name: "@openclaw/discord",
version: "2026.7.33",
resolvedSpec: "@openclaw/discord@2026.7.33",
},
}),
);
const result = await updateNpmInstalledPlugins({
config: createClawHubInstallConfig({
pluginId: "discord",
installPath,
clawhubUrl: "https://clawhub.ai",
clawhubPackage: "@openclaw/discord",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
spec: "clawhub:@openclaw/discord",
}),
pluginIds: ["discord"],
syncOfficialPluginInstalls: true,
officialPluginUpdateChannel: "extended-stable",
coreVersion: "2026.7.33",
});
expect(npmInstallCall()?.spec).toBe("@openclaw/discord@2026.7.33");
expectRecordFields(result.config.plugins?.installs?.discord, {
source: "npm",
spec: "@openclaw/discord",
version: "2026.7.33",
resolvedSpec: "@openclaw/discord@2026.7.33",
});
});
it("reports npm dry-run versions for trusted official ClawHub artifact fallback", async () => {
const installPath = createInstalledPackageDir({
name: "@openclaw/discord",
@ -4743,6 +4923,62 @@ describe("syncPluginsForUpdateChannel", () => {
});
});
it("uses exact-core npm when an official ClawHub bridge falls back on extended-stable", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromClawHubMock.mockResolvedValue({
ok: false,
code: "package_not_found",
error: "Package not found on ClawHub.",
});
installPluginFromNpmSpecMock.mockResolvedValue(
createSuccessfulNpmUpdateResult({
pluginId: "voice-call",
targetDir: "/tmp/openclaw-plugins/voice-call",
version: "2026.7.33",
npmResolution: {
name: "@openclaw/voice-call",
version: "2026.7.33",
resolvedSpec: "@openclaw/voice-call@2026.7.33",
},
}),
);
const result = await syncPluginsForUpdateChannel({
channel: "extended-stable",
coreVersion: "2026.7.33",
externalizedBundledPluginBridges: [
{
bundledPluginId: "voice-call",
preferredSource: "clawhub",
clawhubSpec: "clawhub:@openclaw/voice-call",
npmSpec: "@openclaw/voice-call",
channelIds: ["voice-call"],
},
],
config: {
channels: { "voice-call": { enabled: true } },
plugins: {
installs: {
"voice-call": {
source: "path",
sourcePath: appBundledPluginRoot("voice-call"),
installPath: appBundledPluginRoot("voice-call"),
},
},
},
},
});
expect(npmInstallCall()?.spec).toBe("@openclaw/voice-call@2026.7.33");
expect(npmInstallCall()?.trustedSourceLinkedOfficialInstall).toBe(true);
expectRecordFields(result.config.plugins?.installs?.["voice-call"], {
source: "npm",
spec: "@openclaw/voice-call",
version: "2026.7.33",
resolvedSpec: "@openclaw/voice-call@2026.7.33",
});
});
it("does not fall back from ClawHub to non-OpenClaw npm packages", async () => {
resolveBundledPluginSourcesMock.mockReturnValue(new Map());
installPluginFromClawHubMock.mockResolvedValue({

View file

@ -947,6 +947,7 @@ function resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate(params: {
effectiveClawHubSpec?: string;
recordClawHubSpec?: string;
updateChannel?: UpdateChannel;
coreVersion?: string;
}): {
installSpec: string;
recordSpec: string;
@ -995,6 +996,8 @@ function resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate(params: {
return resolveNpmInstallSpecsForUpdateChannel({
spec: officialSpec,
updateChannel: params.updateChannel,
officialPackageName,
coreVersion: params.coreVersion,
});
}
@ -1059,25 +1062,35 @@ function resolveNpmUpdateSpecs(params: {
specOverride?: string;
officialSpecOverride?: string;
updateChannel?: UpdateChannel;
officialPackageName?: string;
coreVersion?: string;
}): {
installSpec?: string;
recordSpec?: string;
fallbackSpec?: string;
fallbackLabel?: string;
} {
const recordSpec = params.specOverride ?? params.officialSpecOverride ?? params.record.spec;
const recordSpec =
params.specOverride ??
(params.updateChannel === "extended-stable" && params.record.spec
? params.record.spec
: (params.officialSpecOverride ?? params.record.spec));
if (!recordSpec) {
return {};
}
if (params.specOverride) {
return {
installSpec: recordSpec,
recordSpec,
};
return resolveNpmInstallSpecsForUpdateChannel({
spec: recordSpec,
updateChannel: params.updateChannel,
officialPackageName: params.officialPackageName,
coreVersion: params.coreVersion,
});
}
return resolveNpmInstallSpecsForUpdateChannel({
spec: recordSpec,
updateChannel: params.updateChannel,
officialPackageName: params.officialPackageName,
coreVersion: params.coreVersion,
});
}
@ -1367,6 +1380,7 @@ export async function updateNpmInstalledPlugins(params: {
dryRun?: boolean;
updateChannel?: UpdateChannel;
officialPluginUpdateChannel?: UpdateChannel;
coreVersion?: string;
dangerouslyForceUnsafeInstall?: boolean;
specOverrides?: Record<string, string>;
onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise<boolean>;
@ -1443,13 +1457,13 @@ export async function updateNpmInstalledPlugins(params: {
continue;
}
const officialNpmSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record })
: undefined;
const trustedOfficialNpmSpec = resolveTrustedSourceLinkedOfficialNpmSpec({ pluginId, record });
const officialNpmSpec = params.syncOfficialPluginInstalls ? trustedOfficialNpmSpec : undefined;
const officialClawHubSpec = params.syncOfficialPluginInstalls
? resolveTrustedSourceLinkedOfficialClawHubSpec({ pluginId, record })
: undefined;
const officialSyncUpdateChannel = params.officialPluginUpdateChannel ?? params.updateChannel;
const officialNpmPackageName = resolveNpmSpecPackageName(trustedOfficialNpmSpec);
if (normalizedPluginConfig) {
const enableState = resolveEffectiveEnableState({
@ -1484,6 +1498,8 @@ export async function updateNpmInstalledPlugins(params: {
specOverride: params.specOverrides?.[pluginId],
officialSpecOverride: officialNpmSpec,
updateChannel: officialNpmSpec ? officialSyncUpdateChannel : params.updateChannel,
officialPackageName: officialNpmPackageName,
coreVersion: params.coreVersion,
})
: undefined;
const clawhubSpecs =
@ -1506,6 +1522,10 @@ export async function updateNpmInstalledPlugins(params: {
: record.source === "clawhub"
? clawhubSpecs?.recordSpec
: record.spec;
const preserveNpmRecordIntent =
record.source === "npm" &&
npmSpecs?.installSpec !== npmSpecs?.recordSpec &&
(officialNpmSpec ? officialSyncUpdateChannel : params.updateChannel) === "extended-stable";
const officialNpmFallbackSpecs =
record.source === "clawhub"
? resolveTrustedSourceLinkedOfficialNpmFallbackForClawHubUpdate({
@ -1516,6 +1536,7 @@ export async function updateNpmInstalledPlugins(params: {
updateChannel: params.syncOfficialPluginInstalls
? officialSyncUpdateChannel
: params.updateChannel,
coreVersion: params.coreVersion,
})
: null;
let officialNpmFallbackInstallSpec = officialNpmFallbackSpecs?.installSpec;
@ -1682,7 +1703,7 @@ export async function updateNpmInstalledPlugins(params: {
const nextRecordSpec = resolveNpmInstallRecordSpec({
requestedSpec: recordSpec,
resolution: metadataResult.metadata,
pinResolvedRegistrySpec: true,
pinResolvedRegistrySpec: !preserveNpmRecordIntent,
});
if (nextRecordSpec !== record.spec) {
const resolutionFields = buildNpmResolutionInstallFields(metadataResult.metadata);
@ -2292,8 +2313,10 @@ export async function updateNpmInstalledPlugins(params: {
requestedSpec: usedOfficialNpmFallback ? officialNpmFallbackRecordSpec : recordSpec,
resolution: npmResult.npmResolution,
pinResolvedRegistrySpec:
(params.syncOfficialPluginInstalls && trustedSourceLinkedOfficialInstall) ||
usedOfficialNpmFallback,
(params.syncOfficialPluginInstalls &&
trustedSourceLinkedOfficialInstall &&
!preserveNpmRecordIntent) ||
(usedOfficialNpmFallback && officialSyncUpdateChannel !== "extended-stable"),
}),
installPath: result.targetDir,
version: nextVersion,
@ -2382,6 +2405,7 @@ export async function updateNpmInstalledPlugins(params: {
export async function syncPluginsForUpdateChannel(params: {
config: OpenClawConfig;
channel: UpdateChannel;
coreVersion?: string;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
logger?: PluginUpdateLogger;
@ -2504,8 +2528,18 @@ export async function syncPluginsForUpdateChannel(params: {
targetPluginId,
npmSpec,
});
const channelNpmSpecs =
npmSpec && trustedSourceLinkedOfficialInstall
? resolveNpmInstallSpecsForUpdateChannel({
spec: npmSpec,
updateChannel: params.channel,
officialPackageName: resolveNpmSpecPackageName(npmSpec),
coreVersion: params.coreVersion,
})
: null;
const effectiveNpmSpec = channelNpmSpecs?.installSpec ?? npmSpec;
let installSource = preferredSource;
let installSpec = preferredSource === "clawhub" ? clawhubSpec : npmSpec;
let installSpec = preferredSource === "clawhub" ? clawhubSpec : effectiveNpmSpec;
let result:
| Awaited<ReturnType<typeof installPluginFromNpmSpec>>
| Awaited<ReturnType<typeof installPluginFromClawHub>>;
@ -2528,13 +2562,13 @@ export async function syncPluginsForUpdateChannel(params: {
logger,
});
if (!result.ok && npmSpec && shouldFallbackClawHubBridgeToNpm({ result, npmSpec })) {
const warning = `ClawHub ${clawhubSpec} unavailable for ${targetPluginId}; falling back to npm ${npmSpec}.`;
const warning = `ClawHub ${clawhubSpec} unavailable for ${targetPluginId}; falling back to npm ${effectiveNpmSpec}.`;
summary.warnings.push(warning);
logger.warn?.(warning);
installSource = "npm";
installSpec = npmSpec;
installSpec = effectiveNpmSpec;
result = await installPluginFromNpmSpec({
spec: npmSpec,
spec: effectiveNpmSpec,
config: params.config,
mode: "update",
expectedPluginId: targetPluginId,
@ -2544,7 +2578,7 @@ export async function syncPluginsForUpdateChannel(params: {
}
} else {
result = await installPluginFromNpmSpec({
spec: npmSpec,
spec: effectiveNpmSpec,
config: params.config,
mode: "update",
expectedPluginId: targetPluginId,
@ -2609,9 +2643,13 @@ export async function syncPluginsForUpdateChannel(params: {
pluginId: resolvedPluginId,
source: "npm",
spec: resolveNpmInstallRecordSpec({
requestedSpec: installSpec,
requestedSpec:
params.channel === "extended-stable" && installSource === "npm"
? (channelNpmSpecs?.recordSpec ?? installSpec)
: installSpec,
resolution: npmResult.npmResolution,
pinResolvedRegistrySpec: trustedSourceLinkedOfficialInstall,
pinResolvedRegistrySpec:
trustedSourceLinkedOfficialInstall && params.channel !== "extended-stable",
}),
installPath: result.targetDir,
version: nextVersion,

View file

@ -97,3 +97,28 @@ describe("shouldRequireNpmDistTagMirrorAuth", () => {
).toBe(false);
});
});
describe("extended-stable npm publish override", () => {
it("publishes final patch 33 and later to extended-stable without mirrors", () => {
expect(resolveNpmPublishPlan("2026.7.33", undefined, "extended-stable")).toEqual({
channel: "stable",
publishTag: "extended-stable",
mirrorDistTags: [],
});
expect(resolveNpmPublishPlan("2026.7.34", "2026.8.1-beta.1", "extended-stable")).toEqual({
channel: "stable",
publishTag: "extended-stable",
mirrorDistTags: [],
});
});
it.each([
["pre-.33 final", "2026.7.32", "extended-stable"],
["correction", "2026.7.33-1", "extended-stable"],
["alpha", "2026.7.33-alpha.1", "extended-stable"],
["beta", "2026.7.33-beta.1", "extended-stable"],
["open override", "2026.7.33", "latest"],
])("rejects %s releases", (_label, version, override) => {
expect(() => resolveNpmPublishPlan(version, undefined, override)).toThrow();
});
});

View file

@ -12,6 +12,7 @@ import {
collectPublishablePluginPackageErrors,
OPENCLAW_PLUGIN_NPM_REPOSITORY_URL,
parsePluginReleaseArgs,
parsePluginNpmReleaseArgs,
parsePluginReleaseSelection,
parsePluginReleaseSelectionMode,
resolveChangedPublishablePluginPackages,
@ -106,6 +107,36 @@ describe("parsePluginReleaseArgs", () => {
pluginsFlagProvided: false,
});
});
it("accepts only the closed extended-stable npm tag override", () => {
expect(
parsePluginNpmReleaseArgs([
"--selection-mode",
"all-publishable",
"--npm-dist-tag",
"extended-stable",
]),
).toMatchObject({ npmDistTag: "extended-stable" });
expect(() => parsePluginNpmReleaseArgs(["--npm-dist-tag", "latest"])).toThrow(
'Unknown npm dist-tag override: latest. Expected "extended-stable".',
);
});
it("requires extended-stable publication to use all-publishable without a plugin list", () => {
expect(() => parsePluginNpmReleaseArgs(["--npm-dist-tag", "extended-stable"])).toThrow(
"extended-stable requires --selection-mode all-publishable",
);
expect(() =>
parsePluginNpmReleaseArgs([
"--selection-mode",
"selected",
"--plugins",
"@openclaw/slack",
"--npm-dist-tag",
"extended-stable",
]),
).toThrow("extended-stable requires --selection-mode all-publishable");
});
});
function externalPluginContract(version: string) {
@ -493,6 +524,50 @@ describe("collectPublishablePluginPackages", () => {
]);
});
it("uses extended-stable for every publishable plugin at the exact root version", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
writeJsonFile(join(repoDir, "package.json"), { version: "2026.7.33" });
writePluginReadme(repoDir, "demo-plugin");
writeJsonFile(join(repoDir, "extensions", "demo-plugin", "package.json"), {
name: "@openclaw/demo-plugin",
version: "2026.7.33",
type: "module",
repository: { type: "git", url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL },
openclaw: {
extensions: ["./index.ts"],
...externalPluginContract("2026.7.33"),
install: { npmSpec: "@openclaw/demo-plugin" },
release: { publishToNpm: true },
},
});
expect(
collectPublishablePluginPackages(repoDir, { npmDistTag: "extended-stable" }),
).toMatchObject([{ version: "2026.7.33", publishTag: "extended-stable" }]);
});
it("rejects extended-stable plugins whose version differs from core", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
writeJsonFile(join(repoDir, "package.json"), { version: "2026.7.34" });
writePluginReadme(repoDir, "demo-plugin");
writeJsonFile(join(repoDir, "extensions", "demo-plugin", "package.json"), {
name: "@openclaw/demo-plugin",
version: "2026.7.33",
type: "module",
repository: { type: "git", url: OPENCLAW_PLUGIN_NPM_REPOSITORY_URL },
openclaw: {
extensions: ["./index.ts"],
...externalPluginContract("2026.7.33"),
install: { npmSpec: "@openclaw/demo-plugin" },
release: { publishToNpm: true },
},
});
expect(() =>
collectPublishablePluginPackages(repoDir, { npmDistTag: "extended-stable" }),
).toThrow("must match root package version 2026.7.34");
});
it("collects exact release dependencies that must match npm latest", () => {
const repoDir = makeTempRepoRoot(tempDirs, "openclaw-plugin-npm-release-");
mkdirSync(join(repoDir, "extensions", "demo-plugin"), { recursive: true });

View file

@ -286,6 +286,46 @@ describe("extended-stable npm run identity", () => {
).not.toThrow();
});
it("accepts only a completed successful Plugin NPM Release run on the exact branch and SHA", () => {
const pluginRun = {
workflowName: "Plugin NPM Release",
displayTitle: `Plugin NPM Release [extended-stable] ${sha}`,
event: "workflow_dispatch",
status: "completed",
conclusion: "success",
headBranch: branch,
headSha: sha,
};
expect(
validateExtendedStableRunIdentity({
run: pluginRun,
kind: "plugin",
npmDistTag: "extended-stable",
expectedBranch: branch,
expectedSha: sha,
}),
).toBe(pluginRun);
for (const changes of [
{ workflowName: "OpenClaw NPM Release" },
{ displayTitle: `Plugin NPM Release [default] ${sha}` },
{ displayTitle: `Plugin NPM Release [extended-stable] ${"b".repeat(40)}` },
{ status: "in_progress" },
{ conclusion: "failure" },
{ headBranch: "main" },
{ headSha: "b".repeat(40) },
]) {
expect(() =>
validateExtendedStableRunIdentity({
run: { ...pluginRun, ...changes },
kind: "plugin",
npmDistTag: "extended-stable",
expectedBranch: branch,
expectedSha: sha,
}),
).toThrow();
}
});
it.each([
["wrong branch", { headBranch: "main" }],
["missing branch", { headBranch: undefined }],

View file

@ -12,6 +12,7 @@ type Workflow = {
inputs?: {
bypass_extended_stable_guard?: { default?: boolean; type?: string };
npm_dist_tag?: { options?: string[] };
plugin_npm_run_id?: { required?: boolean; type?: string };
};
};
};
@ -111,10 +112,34 @@ describe("minimal npm extended-stable workflow", () => {
const raw = readFileSync(workflowPath, "utf8");
expect(raw).toContain("--json workflowName,headBranch,headSha,event,conclusion,url");
expect(raw).toContain("--json workflowName,headBranch,headSha,event,status,conclusion,url");
expect(raw.match(/openclaw-npm-extended-stable-release\.mjs verify-run/g)).toHaveLength(2);
expect(raw.match(/openclaw-npm-extended-stable-release\.mjs verify-run/g)).toHaveLength(3);
expect(raw).toContain("openclaw-npm-extended-stable-release.mjs verify-manifest");
});
it("requires and authenticates the plugin npm run before an extended-stable core publish", () => {
const parsed = workflow();
expect(parsed.on?.workflow_dispatch?.inputs?.plugin_npm_run_id).toMatchObject({
required: false,
type: "string",
});
const required = step(
parsed.jobs?.validate_publish_request,
"Require preflight artifact promotion on real publish",
);
expect(required.env?.PLUGIN_NPM_RUN_ID).toBe("${{ inputs.plugin_npm_run_id }}");
expect(required.run).toContain("Extended-stable publish requires plugin_npm_run_id");
const verify = step(
parsed.jobs?.publish_openclaw_npm,
"Verify plugin npm release run metadata",
);
expect(verify.env?.RUN_KIND).toBe("plugin");
expect(verify.run).toContain(
"--json workflowName,displayTitle,headBranch,headSha,event,status,conclusion,url",
);
expect(verify.run).toContain("openclaw-npm-extended-stable-release.mjs verify-run");
});
it("captures selector fail closed, publishes extended-stable, retries, and summarizes", () => {
const parsed = workflow();
const publish = parsed.jobs?.publish_openclaw_npm;

View file

@ -0,0 +1,109 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const workflowPath = ".github/workflows/plugin-npm-release.yml";
type Step = { env?: Record<string, string>; name?: string; run?: string };
type Job = {
environment?: string;
if?: string;
needs?: string[] | string;
steps?: Step[];
strategy?: { matrix?: { plugin?: string } };
};
type Workflow = {
on?: {
workflow_dispatch?: {
inputs?: {
npm_dist_tag?: { default?: string; options?: string[]; type?: string };
};
};
};
jobs?: Record<string, Job>;
};
function workflow(): Workflow {
return parse(readFileSync(workflowPath, "utf8")) as Workflow;
}
function step(job: Job | undefined, name: string): Step {
const found = job?.steps?.find((candidate) => candidate.name === name);
if (!found) {
throw new Error(`Missing workflow step: ${name}`);
}
return found;
}
describe("plugin npm extended-stable workflow", () => {
it("exposes only the default behavior and closed extended-stable override", () => {
expect(readFileSync(workflowPath, "utf8")).toContain(
"Plugin NPM Release [{0}] {1}', inputs.npm_dist_tag, inputs.ref",
);
const input = workflow().on?.workflow_dispatch?.inputs?.npm_dist_tag;
expect(input).toEqual({
description: "Optional npm dist-tag override",
required: true,
default: "default",
type: "choice",
options: ["default", "extended-stable"],
});
});
it("uses one override for check, plan, preview, pack, and publish", () => {
const parsed = workflow();
const raw = readFileSync(workflowPath, "utf8");
expect(raw.match(/--npm-dist-tag "\$\{NPM_DIST_TAG\}"/gu)).toHaveLength(2);
const expectedOverride =
"${{ inputs.npm_dist_tag == 'extended-stable' && inputs.npm_dist_tag || '' }}";
for (const name of ["Preview publish command", "Preview npm pack contents", "Publish"]) {
expect(
step(
parsed.jobs?.[name === "Publish" ? "publish_plugins_npm" : "preview_plugin_pack"],
name,
).env,
).toMatchObject({ OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: expectedOverride });
}
});
it("trusts only the canonical monthly branch at the exact checked-out SHA", () => {
const trusted = step(
workflow().jobs?.preview_plugins_npm,
"Validate ref is on a trusted publish branch",
);
expect(trusted.run).toContain("extended-stable/${release_year}.${release_month}.33");
expect(trusted.run).toContain("exact 40-character source SHA");
expect(trusted.run).toContain(
'[[ "${WORKFLOW_REF}" == "refs/heads/${extended_stable_branch}" ]]',
);
expect(trusted.run).toContain(
'[[ "$(git rev-parse HEAD)" == "$(git rev-parse "refs/remotes/origin/${extended_stable_branch}")" ]]',
);
});
it("publishes extended-stable with OIDC only and verifies every package tag", () => {
const parsed = workflow();
const publish = step(parsed.jobs?.publish_plugins_npm, "Publish");
const tokenExpression =
"${{ inputs.npm_dist_tag != 'extended-stable' && secrets.NPM_TOKEN || '' }}";
expect(publish.env).toMatchObject({
NODE_AUTH_TOKEN: tokenExpression,
NPM_TOKEN: tokenExpression,
OPENCLAW_NPM_PUBLISH_AUTH_MODE: "trusted-publisher",
});
expect(parsed.jobs?.reconcile_plugins_npm).toBeUndefined();
expect(readFileSync(workflowPath, "utf8")).not.toContain(
'npm dist-tag add "${PACKAGE_NAME}@${PACKAGE_VERSION}" extended-stable',
);
const verify = parsed.jobs?.verify_plugins_npm;
expect(verify?.needs).toEqual(["preview_plugins_npm", "publish_plugins_npm"]);
expect(verify?.if).toContain("always()");
expect(verify?.if).toContain("has_candidates == 'false'");
expect(verify?.strategy?.matrix?.plugin).toContain("all_matrix");
const readback = step(verify, "Verify complete plugin registry readback");
expect(readback.run).toContain('npm view "${PACKAGE_NAME}@${PACKAGE_VERSION}" version');
expect(readback.run).toContain('npm view "${PACKAGE_NAME}@extended-stable" version');
expect(readback.run).toContain("OIDC-only source workflow does not mutate tags");
});
});

View file

@ -1,16 +1,45 @@
// Plugin NPM Publish tests cover publish wrapper argument safety.
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
import { chmodSync, mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
const scriptPath = "scripts/plugin-npm-publish.sh";
function runPluginPublishWrapper(args: string[]) {
const tempDirs: string[] = [];
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
rmSync(dir, { force: true, recursive: true });
}
});
function runPluginPublishWrapper(args: string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync("bash", [scriptPath, ...args], {
cwd: process.cwd(),
encoding: "utf8",
env: { ...process.env, ...env },
});
}
function makePackage(version: string): { packageDir: string; path: string } {
const root = mkdtempSync(join(tmpdir(), "openclaw-plugin-publish-test-"));
tempDirs.push(root);
const packageDir = join(root, "plugin");
const binDir = join(root, "bin");
mkdirSync(packageDir, { recursive: true });
mkdirSync(binDir, { recursive: true });
writeFileSync(
join(packageDir, "package.json"),
JSON.stringify({ name: "@openclaw/demo", version }),
);
const npmPath = join(binDir, "npm");
writeFileSync(npmPath, "#!/bin/sh\nexit 1\n");
chmodSync(npmPath, 0o755);
return { packageDir, path: `${binDir}${delimiter}${process.env.PATH ?? ""}` };
}
describe("plugin npm publish wrapper", () => {
it("prints help before package or npm checks", () => {
const result = runPluginPublishWrapper(["--help"]);
@ -47,4 +76,28 @@ describe("plugin npm publish wrapper", () => {
expect(result.stdout).toBe("");
expect(result.stderr.trim()).toBe("unexpected plugin npm publish argument: extra");
});
it("uses the extended-stable plan without latest or beta mirrors", () => {
const fixture = makePackage("2026.7.33");
const result = runPluginPublishWrapper(["--dry-run", fixture.packageDir], {
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: "extended-stable",
PATH: fixture.path,
});
expect(result.status).toBe(0);
expect(result.stdout).toContain("Resolved publish tag: extended-stable");
expect(result.stdout).toContain("Resolved mirror dist-tags: <none>");
expect(result.stdout).toContain("npm publish --access public --tag extended-stable");
});
it("rejects extended-stable versions below patch 33", () => {
const fixture = makePackage("2026.7.32");
const result = runPluginPublishWrapper(["--dry-run", fixture.packageDir], {
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: "extended-stable",
PATH: fixture.path,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain("PATCH >= 33");
});
});