fix(release): allow SHA-only extended-stable preflight (#101466)

This commit is contained in:
Kevin Lin 2026-07-07 07:45:55 -07:00 committed by GitHub
parent fdc7892a6e
commit 72ca911e3c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 135 additions and 24 deletions

View file

@ -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 }}

View file

@ -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"),
});
}

View file

@ -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,

View file

@ -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");

View file

@ -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(