diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index eacd9c91b8d..be3898963bd 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -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 }} diff --git a/.github/workflows/plugin-npm-release.yml b/.github/workflows/plugin-npm-release.yml index eeef22a5a07..8ecaa253f95 100644 --- a/.github/workflows/plugin-npm-release.yml +++ b/.github/workflows/plugin-npm-release.yml @@ -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 diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index f08d263ebef..9ed2dac9375 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -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. diff --git a/docs/cli/update.md b/docs/cli/update.md index d2afa814a55..70e9e51ef1f 100644 --- a/docs/cli/update.md +++ b/docs/cli/update.md @@ -102,14 +102,14 @@ openclaw update repair --acknowledge-clawhub-risk openclaw update repair --json ``` -| Flag | Description | -| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `--channel ` | 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 ` | 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 ` | 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 ` | 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 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 diff --git a/docs/install/development-channels.md b/docs/install/development-channels.md index 4ebf79db167..a7d4891dc61 100644 --- a/docs/install/development-channels.md +++ b/docs/install/development-channels.md @@ -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 diff --git a/docs/install/updating.md b/docs/install/updating.md index e47022da9a2..b1f7853eb76 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -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 diff --git a/docs/reference/RELEASING.md b/docs/reference/RELEASING.md index 0ab244437b2..c178bf56bd7 100644 --- a/docs/reference/RELEASING.md +++ b/docs/reference/RELEASING.md @@ -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= \ - -f full_release_validation_run_id= + -f full_release_validation_run_id= \ + -f 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 diff --git a/scripts/lib/npm-publish-plan.mjs b/scripts/lib/npm-publish-plan.mjs index c0caf705bba..fdeb8c177da 100644 --- a/scripts/lib/npm-publish-plan.mjs +++ b/scripts/lib/npm-publish-plan.mjs @@ -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", diff --git a/scripts/lib/plugin-npm-release.ts b/scripts/lib/plugin-npm-release.ts index 25c1f13aa27..6f5bd79a300 100644 --- a/scripts/lib/plugin-npm-release.ts +++ b/scripts/lib/plugin-npm-release.ts @@ -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 || ""} 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" diff --git a/scripts/openclaw-npm-extended-stable-release.mjs b/scripts/openclaw-npm-extended-stable-release.mjs index 0d719f492e9..daa88fbc1ae 100644 --- a/scripts/openclaw-npm-extended-stable-release.mjs +++ b/scripts/openclaw-npm-extended-stable-release.mjs @@ -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) { diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh index b668ae04d15..fde4c8d5587 100644 --- a/scripts/plugin-npm-publish.sh +++ b/scripts/plugin-npm-publish.sh @@ -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, diff --git a/scripts/plugin-npm-release-check.ts b/scripts/plugin-npm-release-check.ts index 4d948234446..b1445a6877b 100644 --- a/scripts/plugin-npm-release-check.ts +++ b/scripts/plugin-npm-release-check.ts @@ -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" diff --git a/scripts/plugin-npm-release-plan.ts b/scripts/plugin-npm-release-plan.ts index feae4147ddd..b8c649c0065 100644 --- a/scripts/plugin-npm-release-plan.ts +++ b/scripts/plugin-npm-release-plan.ts @@ -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, }); } diff --git a/src/cli/plugins-cli.update.test.ts b/src/cli/plugins-cli.update.test.ts index c36c843cdb5..e3913144e92 100644 --- a/src/cli/plugins-cli.update.test.ts +++ b/src/cli/plugins-cli.update.test.ts @@ -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", diff --git a/src/cli/plugins-update-command.ts b/src/cli/plugins-update-command.ts index 28e4918022b..5fe763564da 100644 --- a/src/cli/plugins-update-command.ts +++ b/src/cli/plugins-update-command.ts @@ -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, diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 431c3b5b3e2..beeca11bf3d 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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 () => { diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 5cb55589291..f9bcceac7d6 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -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, diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts index 490ccc24c65..c2a5af61ff5 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.test.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.test.ts @@ -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; + 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).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).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).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; 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; expectRecordFields(persistedRecords.brave, { - spec: "@openclaw/brave-plugin@2026.5.4-beta.1", + spec: "@openclaw/brave-plugin", }); expect(mockCallArg(mocks.writePersistedInstalledPluginIndexInstallRecords, 0, 1)).toEqual({ env: {}, diff --git a/src/commands/doctor/shared/missing-configured-plugin-install.ts b/src/commands/doctor/shared/missing-configured-plugin-install.ts index 6547136e60d..460cfd91137 100644 --- a/src/commands/doctor/shared/missing-configured-plugin-install.ts +++ b/src/commands/doctor/shared/missing-configured-plugin-install.ts @@ -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; 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) => { diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts index 669929c300b..1da3933bfcb 100644 --- a/src/commands/onboarding-plugin-install.test.ts +++ b/src/commands/onboarding-plugin-install.test.ts @@ -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."; diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts index 28984746926..2ecc465662f 100644 --- a/src/commands/onboarding-plugin-install.ts +++ b/src/commands/onboarding-plugin-install.ts @@ -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, diff --git a/src/plugins/install-channel-specs.test.ts b/src/plugins/install-channel-specs.test.ts new file mode 100644 index 00000000000..304cac9f579 --- /dev/null +++ b/src/plugins/install-channel-specs.test.ts @@ -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", + }); + }); +}); diff --git a/src/plugins/install-channel-specs.ts b/src/plugins/install-channel-specs.ts index 122b1b90d7b..caa89a1b95c 100644 --- a/src/plugins/install-channel-specs.ts +++ b/src/plugins/install-channel-specs.ts @@ -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, diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 5f074284d58..47d2ae99eeb 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -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({ diff --git a/src/plugins/update.ts b/src/plugins/update.ts index f728c08f6ea..41786e8c050 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -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; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; @@ -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> | Awaited>; @@ -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, diff --git a/test/npm-publish-plan.test.ts b/test/npm-publish-plan.test.ts index e7445bcf23c..84a367f993f 100644 --- a/test/npm-publish-plan.test.ts +++ b/test/npm-publish-plan.test.ts @@ -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(); + }); +}); diff --git a/test/plugin-npm-release.test.ts b/test/plugin-npm-release.test.ts index 9b242128b40..1936a411427 100644 --- a/test/plugin-npm-release.test.ts +++ b/test/plugin-npm-release.test.ts @@ -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 }); diff --git a/test/scripts/openclaw-npm-extended-stable-release.test.ts b/test/scripts/openclaw-npm-extended-stable-release.test.ts index 36992ff8ea0..28b8ff29519 100644 --- a/test/scripts/openclaw-npm-extended-stable-release.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-release.test.ts @@ -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 }], diff --git a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts index e795a1ccee9..861a8fe5fd7 100644 --- a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts @@ -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; diff --git a/test/scripts/plugin-npm-extended-stable-workflow.test.ts b/test/scripts/plugin-npm-extended-stable-workflow.test.ts new file mode 100644 index 00000000000..a0f68e41caa --- /dev/null +++ b/test/scripts/plugin-npm-extended-stable-workflow.test.ts @@ -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; 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; +}; + +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"); + }); +}); diff --git a/test/scripts/plugin-npm-publish.test.ts b/test/scripts/plugin-npm-publish.test.ts index 78f339b8caf..ae62b870ff0 100644 --- a/test/scripts/plugin-npm-publish.test.ts +++ b/test/scripts/plugin-npm-publish.test.ts @@ -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: "); + 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"); + }); });