diff --git a/.github/workflows/openclaw-npm-release.yml b/.github/workflows/openclaw-npm-release.yml index be3898963bd..96b6c0ad8a9 100644 --- a/.github/workflows/openclaw-npm-release.yml +++ b/.github/workflows/openclaw-npm-release.yml @@ -133,6 +133,7 @@ jobs: - name: Validate npm release request env: BYPASS_EXTENDED_STABLE_GUARD: ${{ inputs.bypass_extended_stable_guard }} + PREFLIGHT_ONLY: ${{ inputs.preflight_only }} RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }} RELEASE_TAG: ${{ inputs.tag }} NPM_WORKFLOW_REF: ${{ github.ref }} diff --git a/scripts/openclaw-npm-extended-stable-release.mjs b/scripts/openclaw-npm-extended-stable-release.mjs index daa88fbc1ae..29d4f4e3614 100644 --- a/scripts/openclaw-npm-extended-stable-release.mjs +++ b/scripts/openclaw-npm-extended-stable-release.mjs @@ -72,8 +72,18 @@ export function validateNpmPublishBoundary( export function validateExtendedStableNpmReleaseRequest(request) { const bypassExtendedStableGuard = request.bypassExtendedStableGuard ?? false; requireExtendedStableBypassTag(request.npmDistTag, bypassExtendedStableGuard); - const taggedVersion = request.releaseTag.startsWith("v") - ? parseReleaseVersion(request.releaseTag.slice(1)) + const shaPreflight = + request.preflightOnly === true && /^[0-9a-f]{40}$/iu.test(request.releaseTag); + if ( + shaPreflight && + request.checkoutSha && + request.releaseTag.toLowerCase() !== request.checkoutSha.toLowerCase() + ) { + throw new Error("Validation-only SHA must match the checked-out commit exactly."); + } + const effectiveReleaseTag = shaPreflight ? `v${request.packageVersion}` : request.releaseTag; + const taggedVersion = effectiveReleaseTag.startsWith("v") + ? parseReleaseVersion(effectiveReleaseTag.slice(1)) : null; if (request.npmDistTag !== "extended-stable") { @@ -89,7 +99,7 @@ export function validateExtendedStableNpmReleaseRequest(request) { if ( taggedVersion === null || - request.releaseTag !== `v${taggedVersion.version}` || + effectiveReleaseTag !== `v${taggedVersion.version}` || taggedVersion.channel !== "stable" ) { throw new Error( @@ -294,9 +304,11 @@ function validateRequestFromRepository() { const npmDistTag = process.env.RELEASE_NPM_DIST_TAG ?? ""; const releaseTag = process.env.RELEASE_TAG ?? ""; const npmWorkflowRef = process.env.NPM_WORKFLOW_REF ?? ""; + const preflightOnly = process.env.PREFLIGHT_ONLY === "true"; const bypassExtendedStableGuard = parseExtendedStableGuardBypass( process.env.BYPASS_EXTENDED_STABLE_GUARD ?? "", ); + const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version; if (npmDistTag !== "extended-stable") { return validateExtendedStableNpmReleaseRequest({ bypassExtendedStableGuard, @@ -306,26 +318,59 @@ function validateRequestFromRepository() { checkoutSha: "", tagSha: "", extendedStableBranchSha: "", - packageVersion: JSON.parse(readFileSync("package.json", "utf8")).version, + packageVersion, mainPackageVersion: "", }); } - const parsed = releaseTag.startsWith("v") ? parseReleaseVersion(releaseTag.slice(1)) : null; + const shaPreflight = preflightOnly && /^[0-9a-f]{40}$/iu.test(releaseTag); + const effectiveReleaseTag = shaPreflight ? `v${packageVersion}` : releaseTag; + const parsed = effectiveReleaseTag.startsWith("v") + ? parseReleaseVersion(effectiveReleaseTag.slice(1)) + : null; if (!parsed || parsed.channel !== "stable" || parsed.correctionNumber !== undefined) { return validateExtendedStableNpmReleaseRequest({ npmDistTag, bypassExtendedStableGuard, releaseTag, + preflightOnly, npmWorkflowRef, checkoutSha: "", tagSha: "", extendedStableBranchSha: "", - packageVersion: JSON.parse(readFileSync("package.json", "utf8")).version, + packageVersion, mainPackageVersion: "", }); } const extendedStableBranch = `extended-stable/${parsed.year}.${parsed.month}.33`; + if (shaPreflight) { + execFileSync( + "git", + [ + "fetch", + "--no-tags", + "origin", + `+refs/heads/${extendedStableBranch}:refs/remotes/origin/${extendedStableBranch}`, + ...(bypassExtendedStableGuard ? [] : ["+refs/heads/main:refs/remotes/origin/main"]), + ], + { stdio: "inherit" }, + ); + const checkoutSha = git(["rev-parse", "HEAD"]); + return validateExtendedStableNpmReleaseRequest({ + npmDistTag, + bypassExtendedStableGuard, + releaseTag, + preflightOnly, + npmWorkflowRef, + checkoutSha, + tagSha: checkoutSha, + extendedStableBranchSha: git(["rev-parse", `refs/remotes/origin/${extendedStableBranch}`]), + packageVersion, + mainPackageVersion: bypassExtendedStableGuard + ? "" + : packageVersionAt("refs/remotes/origin/main"), + }); + } if (bypassExtendedStableGuard) { execFileSync( "git", @@ -350,7 +395,7 @@ function validateRequestFromRepository() { checkoutSha: git(["rev-parse", "HEAD"]), tagSha: git(["rev-parse", `${releaseTag}^{commit}`]), extendedStableBranchSha: git(["rev-parse", `refs/remotes/origin/${extendedStableBranch}`]), - packageVersion: JSON.parse(readFileSync("package.json", "utf8")).version, + packageVersion, mainPackageVersion: "", }); } @@ -378,7 +423,7 @@ function validateRequestFromRepository() { checkoutSha: git(["rev-parse", "HEAD"]), tagSha: git(["rev-parse", `${releaseTag}^{commit}`]), extendedStableBranchSha: git(["rev-parse", `refs/remotes/origin/${extendedStableBranch}`]), - packageVersion: JSON.parse(readFileSync("package.json", "utf8")).version, + packageVersion, mainPackageVersion: packageVersionAt("refs/remotes/origin/main"), }); } diff --git a/test/scripts/openclaw-npm-extended-stable-release.test.ts b/test/scripts/openclaw-npm-extended-stable-release.test.ts index 28b8ff29519..407d8d6cce7 100644 --- a/test/scripts/openclaw-npm-extended-stable-release.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-release.test.ts @@ -209,6 +209,30 @@ describe("extended-stable npm release request", () => { ).toEqual({ extendedStable: false }); }); + it("accepts a SHA-only extended-stable preflight bound to the branch tip", () => { + expect( + validateExtendedStableNpmReleaseRequest({ + ...valid, + preflightOnly: true, + releaseTag: sha, + }), + ).toEqual({ + extendedStable: true, + releaseVersion: "2026.6.33", + extendedStableBranch: "extended-stable/2026.6.33", + }); + expect(() => + validateExtendedStableNpmReleaseRequest({ + ...valid, + preflightOnly: true, + releaseTag: "b".repeat(40), + }), + ).toThrow(/must match the checked-out commit/u); + expect(() => validateExtendedStableNpmReleaseRequest({ ...valid, releaseTag: sha })).toThrow( + /exact final vYYYY\.M\.P release tag/u, + ); + }); + it("bypasses patch and protected-main policy while preserving canonical branch identity", () => { const bypassed = { ...valid, diff --git a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts index 861a8fe5fd7..4b320224e49 100644 --- a/test/scripts/openclaw-npm-extended-stable-workflow.test.ts +++ b/test/scripts/openclaw-npm-extended-stable-workflow.test.ts @@ -63,6 +63,9 @@ describe("minimal npm extended-stable workflow", () => { expect(step(parsed.jobs?.preflight_openclaw_npm, "Validate npm release request").run).toContain( "openclaw-npm-extended-stable-release.mjs validate-request", ); + expect( + step(parsed.jobs?.preflight_openclaw_npm, "Validate npm release request").env?.PREFLIGHT_ONLY, + ).toBe("${{ inputs.preflight_only }}"); expect( step(parsed.jobs?.validate_publish_request, "Validate npm release request").run, ).toContain("openclaw-npm-extended-stable-release.mjs validate-request"); diff --git a/test/scripts/openclaw-npm-publish.test.ts b/test/scripts/openclaw-npm-publish.test.ts index 2068bcad85d..977ef0d28ab 100644 --- a/test/scripts/openclaw-npm-publish.test.ts +++ b/test/scripts/openclaw-npm-publish.test.ts @@ -1,6 +1,6 @@ // OpenClaw NPM Publish tests cover publish wrapper argument safety. import { execFileSync, spawnSync } from "node:child_process"; -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; @@ -14,14 +14,36 @@ function makeTempDir(prefix: string): string { return dir; } -function runPublishWrapper(args: string[], env: NodeJS.ProcessEnv = {}) { +function runPublishWrapper( + args: string[], + env: NodeJS.ProcessEnv = {}, + cwd: string = process.cwd(), +) { return spawnSync("bash", [scriptPath, ...args], { - cwd: process.cwd(), + cwd, encoding: "utf8", env: { ...process.env, ...env }, }); } +function makeReleaseCheckout(root: string, version: string): string { + const checkout = path.join(root, "checkout"); + const scriptsDir = path.join(checkout, "scripts"); + mkdirSync(scriptsDir, { recursive: true }); + writeFileSync(path.join(checkout, "package.json"), JSON.stringify({ version }), "utf8"); + copyFileSync(scriptPath, path.join(checkout, scriptPath)); + copyFileSync( + "scripts/openclaw-npm-extended-stable-release.mjs", + path.join(checkout, "scripts/openclaw-npm-extended-stable-release.mjs"), + ); + mkdirSync(path.join(scriptsDir, "lib")); + copyFileSync( + "scripts/lib/npm-publish-plan.mjs", + path.join(checkout, "scripts/lib/npm-publish-plan.mjs"), + ); + return checkout; +} + function makePackageTarball(root: string, packageJson?: string): string { const packageDir = path.join(root, "package"); const tarball = path.join(root, "openclaw.tgz"); @@ -85,7 +107,8 @@ describe("openclaw npm publish wrapper", () => { it.each(["beta", "latest"])("publishes the prepared tarball to the %s dist-tag", (distTag) => { const tempRoot = makeTempDir("openclaw-npm-publish-"); const binDir = path.join(tempRoot, "bin"); - const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version as string; + const packageVersion = distTag === "beta" ? "2026.5.32-beta.1" : "2026.5.32"; + const checkout = makeReleaseCheckout(tempRoot, packageVersion); const tarball = makePackageTarball(tempRoot, JSON.stringify({ version: packageVersion })); const npmLog = path.join(tempRoot, "npm.log"); mkdirSync(binDir); @@ -93,10 +116,14 @@ describe("openclaw npm publish wrapper", () => { mode: 0o755, }); - const result = runPublishWrapper(["--publish", tarball], { - OPENCLAW_NPM_PUBLISH_TAG: distTag, - PATH: `${binDir}:${process.env.PATH}`, - }); + const result = runPublishWrapper( + ["--publish", tarball], + { + OPENCLAW_NPM_PUBLISH_TAG: distTag, + PATH: `${binDir}:${process.env.PATH}`, + }, + checkout, + ); expect(result.status).toBe(0); expect(readFileSync(npmLog, "utf8")).toContain( @@ -136,9 +163,15 @@ describe("openclaw npm publish wrapper", () => { }); it("rejects publishing the current pre-.33 final version to extended-stable", () => { - const result = runPublishWrapper(["--publish"], { - OPENCLAW_NPM_PUBLISH_TAG: "extended-stable", - }); + const tempRoot = makeTempDir("openclaw-npm-publish-"); + const checkout = makeReleaseCheckout(tempRoot, "2026.5.32"); + const result = runPublishWrapper( + ["--publish"], + { + OPENCLAW_NPM_PUBLISH_TAG: "extended-stable", + }, + checkout, + ); expect(result.status).not.toBe(0); expect(result.stderr).toContain( @@ -149,17 +182,22 @@ describe("openclaw npm publish wrapper", () => { it("publishes a pre-.33 final version to extended-stable with the explicit bypass", () => { const tempRoot = makeTempDir("openclaw-npm-publish-"); const binDir = path.join(tempRoot, "bin"); + const checkout = makeReleaseCheckout(tempRoot, "2026.5.32"); const npmLog = path.join(tempRoot, "npm.log"); mkdirSync(binDir); writeFileSync(path.join(binDir, "npm"), `#!/bin/sh\nprintf '%s\\n' "$*" > "${npmLog}"\n`, { mode: 0o755, }); - const result = runPublishWrapper(["--publish"], { - BYPASS_EXTENDED_STABLE_GUARD: "true", - OPENCLAW_NPM_PUBLISH_TAG: "extended-stable", - PATH: `${binDir}:${process.env.PATH}`, - }); + const result = runPublishWrapper( + ["--publish"], + { + BYPASS_EXTENDED_STABLE_GUARD: "true", + OPENCLAW_NPM_PUBLISH_TAG: "extended-stable", + PATH: `${binDir}:${process.env.PATH}`, + }, + checkout, + ); expect(result.status).toBe(0); expect(readFileSync(npmLog, "utf8")).toContain(