diff --git a/scripts/openclaw-npm-publish.sh b/scripts/openclaw-npm-publish.sh index 95bf6d5b3ae6..eb0eb4f8f27d 100644 --- a/scripts/openclaw-npm-publish.sh +++ b/scripts/openclaw-npm-publish.sh @@ -40,25 +40,43 @@ if [[ -n "${publish_target}" && -f "${publish_target}" ]]; then fi package_version="$(node -p "require('./package.json').version")" -mapfile -t publish_plan < <( +if [[ -n "${publish_target}" ]]; then + if [[ ! -f "${publish_target}" ]]; then + echo "error: npm publish tarball not found: ${publish_target}" >&2 + exit 2 + fi + if ! tarball_package_json="$(tar -xOf "${publish_target}" package/package.json)"; then + echo "error: npm publish tarball is missing a readable package/package.json: ${publish_target}" >&2 + exit 2 + fi + if ! tarball_package_version="$(printf '%s' "${tarball_package_json}" | node -e ' + let input = ""; + process.stdin.on("data", (chunk) => { input += chunk; }); + process.stdin.on("end", () => { + const pkg = JSON.parse(input); + if (!pkg || typeof pkg !== "object" || Array.isArray(pkg) || typeof pkg.version !== "string" || pkg.version.trim() === "") { + throw new Error("package/package.json must contain a nonempty string version"); + } + process.stdout.write(pkg.version.trim()); + }); + ')"; then + echo "error: npm publish tarball package/package.json is malformed or has no valid version: ${publish_target}" >&2 + exit 2 + fi + if [[ "${tarball_package_version}" != "${package_version}" ]]; then + echo "error: npm publish tarball version mismatch: expected ${package_version}, got ${tarball_package_version}" >&2 + exit 2 + fi +fi + +publish_plan="$( PACKAGE_VERSION="${package_version}" REQUESTED_PUBLISH_TAG="${OPENCLAW_NPM_PUBLISH_TAG:-}" \ - node --import tsx --input-type=module <<'EOF' -import { resolveNpmPublishPlan } from "./scripts/openclaw-npm-release-check.ts"; + BYPASS_EXTENDED_STABLE_GUARD="${BYPASS_EXTENDED_STABLE_GUARD:-}" \ + node scripts/openclaw-npm-extended-stable-release.mjs publish-plan +)" -const requestedPublishTag = - process.env.REQUESTED_PUBLISH_TAG === "latest" - ? "latest" - : process.env.REQUESTED_PUBLISH_TAG === "alpha" - ? "alpha" - : "beta"; -const plan = resolveNpmPublishPlan(process.env.PACKAGE_VERSION ?? "", undefined, requestedPublishTag); -console.log(plan.channel); -console.log(plan.publishTag); -EOF -) - -release_channel="${publish_plan[0]}" -publish_tag="${publish_plan[1]}" +release_channel="${publish_plan%%$'\n'*}" +publish_tag="${publish_plan#*$'\n'}" publish_cmd=(npm publish) if [[ -n "${publish_target}" ]]; then publish_cmd+=("${publish_target}") diff --git a/test/scripts/openclaw-npm-publish.test.ts b/test/scripts/openclaw-npm-publish.test.ts index db4c1d9adb82..7eab1a6eb53f 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 { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { execFileSync, spawnSync } from "node:child_process"; +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,13 +14,49 @@ function makeTempDir(prefix: string): string { return dir; } -function runPublishWrapper(args: string[]) { +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"); + mkdirSync(packageDir); + if (packageJson === undefined) { + writeFileSync(path.join(packageDir, "README.md"), "missing package metadata", "utf8"); + } else { + writeFileSync(path.join(packageDir, "package.json"), packageJson, "utf8"); + } + execFileSync("tar", ["-czf", tarball, "-C", root, "package"]); + return tarball; +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { rmSync(dir, { force: true, recursive: true }); @@ -67,4 +103,114 @@ describe("openclaw npm publish wrapper", () => { expect(result.stdout).toBe(""); expect(result.stderr.trim()).toBe("error: unexpected npm publish argument: extra"); }); + + 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 = 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); + writeFileSync(path.join(binDir, "npm"), `#!/bin/sh\nprintf '%s\\n' "$*" > "${npmLog}"\n`, { + mode: 0o755, + }); + + 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( + `publish ${tarball} --access public --tag ${distTag} --provenance`, + ); + expect(result.stdout).toContain(`Resolved publish tag: ${distTag}`); + }); + + it("rejects a tarball whose package version differs from the checkout", () => { + const tempRoot = makeTempDir("openclaw-npm-publish-"); + const packageVersion = JSON.parse(readFileSync("package.json", "utf8")).version as string; + const tarballVersion = `${packageVersion}-mismatch`; + const tarball = makePackageTarball(tempRoot, JSON.stringify({ version: tarballVersion })); + const result = runPublishWrapper(["--publish", tarball], { + OPENCLAW_NPM_PUBLISH_TAG: "extended-stable", + }); + + expect(result.status).toBe(2); + expect(result.stderr).toContain( + `npm publish tarball version mismatch: expected ${packageVersion}, got ${tarballVersion}`, + ); + }); + + it.each([ + ["missing package.json", undefined, "missing a readable package/package.json"], + ["malformed package.json", "{not-json", "package/package.json is malformed"], + ["missing version", JSON.stringify({ name: "openclaw" }), "has no valid version"], + ])("rejects a tarball with %s", (_label, packageJson, expectedError) => { + const tempRoot = makeTempDir("openclaw-npm-publish-"); + const tarball = makePackageTarball(tempRoot, packageJson); + const result = runPublishWrapper(["--publish", tarball], { + OPENCLAW_NPM_PUBLISH_TAG: "extended-stable", + }); + + expect(result.status).toBe(2); + expect(result.stderr).toContain(expectedError); + }); + + it("rejects a pre-.33 final version on 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( + "Extended-stable npm publication requires release patch 33 or above", + ); + }); + + it("routes the prepared 2026.6.33 tarball only to extended-stable", () => { + const tempRoot = makeTempDir("openclaw-npm-publish-"); + const binDir = path.join(tempRoot, "bin"); + const checkout = makeReleaseCheckout(tempRoot, "2026.6.33"); + const tarball = makePackageTarball(tempRoot, JSON.stringify({ version: "2026.6.33" })); + 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", tarball], + { + OPENCLAW_NPM_PUBLISH_TAG: "extended-stable", + PATH: `${binDir}:${process.env.PATH}`, + }, + checkout, + ); + + expect(result.status).toBe(0); + expect(readFileSync(npmLog, "utf8")).toContain( + `publish ${tarball} --access public --tag extended-stable --provenance`, + ); + expect(result.stdout).toContain("Resolved publish tag: extended-stable"); + expect(result.stdout).not.toContain("Resolved publish tag: beta"); + }); + + it("rejects unknown requested dist-tags instead of falling back to beta", () => { + const result = runPublishWrapper(["--publish"], { + OPENCLAW_NPM_PUBLISH_TAG: "nightly", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('Unsupported npm dist-tag "nightly"'); + }); });