mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
a4e728feca
commit
9d2d517296
31 changed files with 1258 additions and 81 deletions
24
.github/workflows/openclaw-npm-release.yml
vendored
24
.github/workflows/openclaw-npm-release.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
107
.github/workflows/plugin-npm-release.yml
vendored
107
.github/workflows/plugin-npm-release.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {},
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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.";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
92
src/plugins/install-channel-specs.test.ts
Normal file
92
src/plugins/install-channel-specs.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
109
test/scripts/plugin-npm-extended-stable-workflow.test.ts
Normal file
109
test/scripts/plugin-npm-extended-stable-workflow.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue