diff --git a/scripts/lib/npm-publish-plan.mjs b/scripts/lib/npm-publish-plan.mjs index a7d5b08c133b..d2a59fa4e78c 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 */ @@ -186,14 +186,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/openclaw-npm-publish.sh b/scripts/openclaw-npm-publish.sh index 46e57b421df5..eb0eb4f8f27d 100644 --- a/scripts/openclaw-npm-publish.sh +++ b/scripts/openclaw-npm-publish.sh @@ -2,11 +2,33 @@ set -euo pipefail -mode="${1:-}" -publish_target="${2:-}" +usage() { + echo "usage: bash scripts/openclaw-npm-publish.sh --publish [package.tgz]" +} -if [[ "${mode}" != "--publish" ]]; then - echo "usage: bash scripts/openclaw-npm-publish.sh --publish [package.tgz]" >&2 +if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then + usage + exit 0 +fi + +if [[ "${1:-}" != "--publish" ]]; then + usage >&2 + exit 2 +fi +shift + +publish_target="" +if [[ "${1:-}" == "--" ]]; then + shift +fi +if [[ "$#" -gt 0 ]]; then + case "$1" in + -*) echo "error: unexpected npm publish target option: $1" >&2; exit 2 ;; + *) publish_target="$1"; shift ;; + esac +fi +if [[ "$#" -gt 0 ]]; then + echo "error: unexpected npm publish argument: $1" >&2 exit 2 fi diff --git a/scripts/plugin-npm-publish.sh b/scripts/plugin-npm-publish.sh index 64cd3b48250d..4505cc7a2203 100644 --- a/scripts/plugin-npm-publish.sh +++ b/scripts/plugin-npm-publish.sh @@ -63,6 +63,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/test/npm-publish-plan.test.ts b/test/npm-publish-plan.test.ts index e7445bcf23c7..84a367f993fa 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/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index 40e1bd7579d5..1bd429fba213 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -1512,7 +1512,7 @@ describe("package artifact reuse", () => { expect(manifestStep.run).toContain('--arg performanceRunId "$PERFORMANCE_RUN_ID"'); }); - it("keeps release publish creation compatible with gh api and prerelease notes", () => { + it("keeps release publish creation compatible while npm rehearsal stays preflight-only", () => { const workflow = readFileSync(RELEASE_PUBLISH_WORKFLOW, "utf8"); const npmWorkflow = readFileSync(".github/workflows/openclaw-npm-release.yml", "utf8"); const fullReleaseWorkflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); @@ -1532,8 +1532,11 @@ describe("package artifact reuse", () => { ); expect(workflow).toContain("preflight-manifest.json"); expect(npmWorkflow).toContain("preflight-manifest.json"); - expect(npmWorkflow).toContain("Verify full release validation run metadata"); - expect(npmWorkflow).toContain("Verify full release validation target"); + expect(npmWorkflow).not.toContain("validate_publish_request:"); + expect(npmWorkflow).not.toContain("publish_openclaw_npm:"); + expect(npmWorkflow).not.toContain("id-token: write"); + expect(npmWorkflow).toContain("This throwaway workflow requires preflight_only=true"); + expect(npmWorkflow).toContain("This throwaway workflow requires a full 40-character commit SHA"); expect(npmWorkflow).not.toContain("Build and smoke test final Docker runtime image"); expect(fullReleaseWorkflow).toContain("docker_runtime_assets_preflight"); expect(fullReleaseWorkflow).not.toContain("Build and smoke test final Docker runtime image"); @@ -1547,12 +1550,6 @@ describe("package artifact reuse", () => { expect(fullReleaseWorkflow).toContain( "needs.docker_runtime_assets_preflight.result == 'success'", ); - expect(npmWorkflow).toContain("full_release_validation_run_id"); - expect(npmWorkflow).toContain("release_publish_run_id"); - expect(npmWorkflow).toContain("Real publish requires full_release_validation_run_id"); - expect(npmWorkflow).toContain( - "Workflow-dispatched real publish requires release_publish_run_id", - ); expect(npmWorkflow).toContain("tarballSha256"); expect(workflow).toContain("Checkout release SHA"); expect(workflow).toContain('git show "${TARGET_SHA}:CHANGELOG.md" > "${changelog_file}"'); @@ -1886,7 +1883,7 @@ describe("package artifact reuse", () => { expect(clawHubReleasePlanScript).toContain("--skip-clawhub"); expect(pluginNpmWorkflow).toContain("Validate release publish approval run"); expect(clawHubWorkflow).toContain("Validate release publish approval run"); - expect(openclawNpmWorkflow).toContain("Validate release publish approval run"); + expect(openclawNpmWorkflow).not.toContain("Validate release publish approval run"); expect(pluginNpmWorkflow).toContain("Check npm package version"); expect(pluginNpmWorkflow).toContain("already_published=true"); expect(pluginNpmWorkflow).toContain( @@ -1894,16 +1891,16 @@ describe("package artifact reuse", () => { ); expect(pluginNpmWorkflow).toContain("Direct Plugin NPM Release dispatch"); expect(clawHubWorkflow).toContain("Direct Plugin ClawHub Release dispatch"); - expect(openclawNpmWorkflow).toContain("Direct OpenClaw npm publish"); + expect(openclawNpmWorkflow).not.toContain("Direct OpenClaw npm publish"); expect(pluginNpmWorkflow).toContain('GITHUB_ACTOR}" != "github-actions[bot]"'); expect(clawHubWorkflow).toContain('GITHUB_ACTOR}" != "github-actions[bot]"'); - expect(openclawNpmWorkflow).toContain('GITHUB_ACTOR}" != "github-actions[bot]"'); + expect(openclawNpmWorkflow).not.toContain('GITHUB_ACTOR}" != "github-actions[bot]"'); expect(pluginNpmWorkflow).toContain("Direct Plugin NPM Release recovery"); expect(clawHubWorkflow).toContain("Direct Plugin ClawHub Release recovery"); - expect(openclawNpmWorkflow).toContain("Direct OpenClaw npm recovery"); + expect(openclawNpmWorkflow).not.toContain("Direct OpenClaw npm recovery"); expect(pluginNpmWorkflow).toContain("validate-release-publish-approval.mjs"); expect(clawHubWorkflow).toContain("validate-release-publish-approval.mjs"); - expect(openclawNpmWorkflow).toContain("validate-release-publish-approval.mjs"); + expect(openclawNpmWorkflow).not.toContain("validate-release-publish-approval.mjs"); expect(approvalScript).toContain("must still be in_progress"); expect(approvalScript).toContain("completed with success/failure"); expect(pluginNpmWorkflow).toContain("environment: npm-release"); @@ -1963,7 +1960,7 @@ describe("package artifact reuse", () => { expect(clawHubNewWorkflow).toContain("Verify bootstrap ClawHub package and trusted publisher"); expect(clawHubNewWorkflow).toContain("/trusted-publisher"); expect(clawHubNewWorkflow).toContain('trustedPublisher?.repository !== "openclaw/openclaw"'); - expect(openclawNpmWorkflow).toContain("environment: npm-release"); + expect(openclawNpmWorkflow).not.toContain("environment: npm-release"); expect(releaseWorkflow).toContain("default: from-validation"); expect(releaseWorkflow).toContain('--release-publish-branch "${CHILD_WORKFLOW_REF}"'); expect(releaseWorkflow).toContain('--release-publish-run-id "${GITHUB_RUN_ID}"');