feat(release): preflight all extended-stable npm packages (#101757)

* feat(release): preflight all extended-stable npm packages

* fix(release): install packed ai runtime in smoke

* fix(release): install sibling tarballs before core

* fix(release): verify unpublished tarballs locally

* fix(release): declare local preflight dependencies

* fix(release): pack workspace dependencies for smoke

* fix(release): accept pnpm pack artifact path

* fix(release): seed ai in sdk smoke

* fix(release): preserve prepublish verifier compatibility
This commit is contained in:
Kevin Lin 2026-07-08 08:01:00 -07:00 committed by GitHub
parent 9d7a6b5da6
commit 9eeebf7cb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 452 additions and 107 deletions

View file

@ -4,7 +4,7 @@ on:
workflow_dispatch:
inputs:
tag:
description: Release tag to publish, or a full 40-character workflow-branch commit SHA for validation-only preflight (for example v2026.3.22 or 0123456789abcdef0123456789abcdef01234567)
description: Release tag to publish, or any full 40-character commit SHA for validation-only preflight (for example v2026.3.22 or 0123456789abcdef0123456789abcdef01234567)
required: true
type: string
preflight_only:
@ -208,19 +208,16 @@ jobs:
RELEASE_SHA=$(git rev-parse HEAD)
RELEASE_BRANCH_REF="refs/remotes/origin/${WORKFLOW_REF_NAME}"
export RELEASE_SHA RELEASE_BRANCH_REF
# Fetch the workflow branch so merge-base ancestry checks keep working
# for older tagged commits contained in a release branch.
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
if [[ "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
BRANCH_SHA="$(git rev-parse "${RELEASE_BRANCH_REF}")"
if [[ "${RELEASE_SHA}" != "${BRANCH_SHA}" ]]; then
echo "Validation-only SHA mode only supports the current ${WORKFLOW_REF_NAME} HEAD." >&2
exit 1
fi
RELEASE_TAG="v$(node -p "require('./package.json').version")"
export RELEASE_TAG
RELEASE_BRANCH_REF="${RELEASE_SHA}"
export RELEASE_BRANCH_REF
echo "Validation-only SHA mode: using synthetic release tag ${RELEASE_TAG} for package metadata checks."
else
# Tagged release validation still proves the commit is contained in
# the workflow branch selected by the operator.
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
RELEASE_TAG="${RELEASE_REF}"
export RELEASE_TAG
fi
@ -232,8 +229,58 @@ jobs:
# IF A CHECK CAN TAKE A LONG TIME, NEEDS LIVE CREDENTIALS, OR IS KNOWN TO BE FLAKY,
# IT BELONGS IN openclaw-release-checks.yml INSTEAD OF BLOCKING npm PUBLISH.
- name: Verify release contents
env:
OPENCLAW_RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR: ${{ steps.ai_runtime_tarballs.outputs.dir }}
run: pnpm release:check
- name: Exercise all extended-stable plugin npm packages
id: plugin_npm_preflight
if: ${{ inputs.npm_dist_tag == 'extended-stable' }}
env:
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: extended-stable
run: |
set -euo pipefail
plan="$RUNNER_TEMP/plugin-npm-extended-stable-preflight.json"
artifact_dir="$RUNNER_TEMP/openclaw-plugin-npm-preflight"
rm -rf "$artifact_dir"
mkdir -p "$artifact_dir"
node --import tsx scripts/plugin-npm-release-plan.ts \
--selection-mode all-publishable \
--npm-dist-tag extended-stable > "$plan"
cp "$plan" "$artifact_dir/plugin-npm-release-plan.json"
mapfile -t package_dirs < <(jq -r '.all[].packageDir' "$plan")
if [[ "${#package_dirs[@]}" == "0" ]]; then
echo "Extended-stable plugin preflight resolved no publishable packages." >&2
exit 1
fi
runtime_args=()
for package_dir in "${package_dirs[@]}"; do
runtime_args+=(--package "$package_dir")
done
node scripts/check-plugin-npm-runtime-builds.mjs "${runtime_args[@]}"
for package_dir in "${package_dirs[@]}"; do
OPENCLAW_PLUGIN_NPM_RUNTIME_BUILD=0 \
OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR="$artifact_dir" \
bash scripts/plugin-npm-publish.sh --pack "$package_dir" >/dev/null
done
(cd "$artifact_dir" && sha256sum ./*.tgz > SHA256SUMS)
package_count="$(jq -r '.all | length' "$plan")"
echo "dir=$artifact_dir" >> "$GITHUB_OUTPUT"
echo "Extended-stable plugin preflight exercised ${package_count} publishable packages."
echo "- Extended-stable plugin packages exercised: ${package_count}" >> "$GITHUB_STEP_SUMMARY"
- name: Upload extended-stable plugin npm packages
if: ${{ inputs.npm_dist_tag == 'extended-stable' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-plugin-npm-preflight-${{ inputs.tag }}
path: ${{ steps.plugin_npm_preflight.outputs.dir }}
if-no-files-found: error
- name: Generate dependency release evidence
id: dependency_evidence
env:

View file

@ -74,12 +74,13 @@ export function validateExtendedStableNpmReleaseRequest(request) {
requireExtendedStableBypassTag(request.npmDistTag, bypassExtendedStableGuard);
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.");
if (shaPreflight) {
if (!/^[0-9a-f]{40}$/iu.test(request.checkoutSha)) {
throw new Error("Validation-only SHA preflight requires the full checked-out commit SHA.");
}
if (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")
@ -112,24 +113,26 @@ export function validateExtendedStableNpmReleaseRequest(request) {
const releaseVersion = taggedVersion.version;
const extendedStableBranch = `extended-stable/${taggedVersion.year}.${taggedVersion.month}.33`;
const expectedWorkflowRef = `refs/heads/${extendedStableBranch}`;
if (request.npmWorkflowRef !== expectedWorkflowRef) {
throw new Error(
`Extended-stable npm workflow ref mismatch: expected ${expectedWorkflowRef}, got ${request.npmWorkflowRef}.`,
);
}
if (request.packageVersion !== releaseVersion) {
throw new Error(
`Extended-stable npm package version mismatch: expected ${releaseVersion}, got ${request.packageVersion}.`,
);
}
const shaValues = [request.checkoutSha, request.tagSha, request.extendedStableBranchSha];
if (shaValues.some((sha) => !/^[0-9a-f]{40}$/iu.test(sha))) {
throw new Error("Extended-stable npm release identity requires full 40-character Git SHAs.");
}
if (new Set(shaValues.map((sha) => sha.toLowerCase())).size !== 1) {
throw new Error("Extended-stable npm checkout, tag, and branch tip SHAs must match exactly.");
if (!shaPreflight) {
const expectedWorkflowRef = `refs/heads/${extendedStableBranch}`;
if (request.npmWorkflowRef !== expectedWorkflowRef) {
throw new Error(
`Extended-stable npm workflow ref mismatch: expected ${expectedWorkflowRef}, got ${request.npmWorkflowRef}.`,
);
}
const shaValues = [request.checkoutSha, request.tagSha, request.extendedStableBranchSha];
if (shaValues.some((sha) => !/^[0-9a-f]{40}$/iu.test(sha))) {
throw new Error("Extended-stable npm release identity requires full 40-character Git SHAs.");
}
if (new Set(shaValues.map((sha) => sha.toLowerCase())).size !== 1) {
throw new Error("Extended-stable npm checkout, tag, and branch tip SHAs must match exactly.");
}
}
if (bypassExtendedStableGuard) {
@ -344,17 +347,13 @@ function validateRequestFromRepository() {
}
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" },
);
if (!bypassExtendedStableGuard) {
execFileSync(
"git",
["fetch", "--no-tags", "origin", "+refs/heads/main:refs/remotes/origin/main"],
{ stdio: "inherit" },
);
}
const checkoutSha = git(["rev-parse", "HEAD"]);
return validateExtendedStableNpmReleaseRequest({
npmDistTag,
@ -364,7 +363,7 @@ function validateRequestFromRepository() {
npmWorkflowRef,
checkoutSha,
tagSha: checkoutSha,
extendedStableBranchSha: git(["rev-parse", `refs/remotes/origin/${extendedStableBranch}`]),
extendedStableBranchSha: "",
packageVersion,
mainPackageVersion: bypassExtendedStableGuard
? ""

View file

@ -1,12 +1,12 @@
#!/usr/bin/env -S node --import tsx
// Openclaw Npm Prepublish Verify script supports OpenClaw repository automation.
import { mkdtempSync, readFileSync, realpathSync, rmSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { formatErrorMessage } from "../src/infra/errors.ts";
import { runNpmVerifyCommand } from "./lib/npm-verify-exec.ts";
import { type NpmVerifyCommandInvocation, runNpmVerifyCommand } from "./lib/npm-verify-exec.ts";
import { runInstalledWorkspaceBootstrapSmoke } from "./lib/workspace-bootstrap-smoke.mjs";
import {
collectInstalledPackageErrors,
@ -14,6 +14,7 @@ import {
resolveInstalledBinaryCommandInvocation,
} from "./openclaw-npm-postpublish-verify.ts";
import { resolveNpmCommandInvocation } from "./openclaw-npm-release-check.ts";
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs";
type InstalledPackageJson = {
version?: string;
@ -69,6 +70,10 @@ export function parseOpenClawNpmPrepublishVerifyArgs(
: { dependencyTarballPaths, help: false, tarballPath };
}
export function usesPreparedLocalDependencyInstall(dependencyTarballCount: number): boolean {
return dependencyTarballCount === 1;
}
function npmExec(args: string[], cwd: string): string {
const invocation = resolveNpmCommandInvocation({
npmArgs: args,
@ -90,21 +95,58 @@ function main(argv = process.argv.slice(2)): void {
const workingDir = mkdtempSync(join(tmpdir(), "openclaw-prepublish-"));
const prefixDir = join(workingDir, "prefix");
try {
npmExec(
[
"install",
"-g",
"--prefix",
let binaryInvocation: NpmVerifyCommandInvocation;
let packageRoot: string;
if (usesPreparedLocalDependencyInstall(args.dependencyTarballPaths.length)) {
mkdirSync(prefixDir, { recursive: true });
writeFileSync(
join(prefixDir, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies: {
"@openclaw/ai": pathToFileURL(realpathSync(args.dependencyTarballPaths[0])).href,
openclaw: pathToFileURL(realpathSync(args.tarballPath)).href,
},
},
null,
2,
)}\n`,
);
npmExec(["install", "--prefix", prefixDir, "--no-fund", "--no-audit"], workingDir);
packageRoot = join(prefixDir, "node_modules", "openclaw");
const binaryPath = join(
prefixDir,
...args.dependencyTarballPaths.map((dependency) => realpathSync(dependency)),
realpathSync(args.tarballPath),
"--no-fund",
"--no-audit",
],
workingDir,
);
const globalRoot = npmExec(["root", "-g", "--prefix", prefixDir], workingDir);
const packageRoot = join(globalRoot, "openclaw");
"node_modules",
".bin",
process.platform === "win32" ? "openclaw.cmd" : "openclaw",
);
binaryInvocation =
process.platform === "win32"
? {
command: resolveWindowsCmdExePath(),
args: ["/d", "/s", "/c", buildCmdExeCommandLine(binaryPath, ["--version"])],
windowsVerbatimArguments: true,
}
: { command: binaryPath, args: ["--version"] };
} else {
npmExec(
[
"install",
"-g",
"--prefix",
prefixDir,
...args.dependencyTarballPaths.map((dependency) => realpathSync(dependency)),
realpathSync(args.tarballPath),
"--no-fund",
"--no-audit",
],
workingDir,
);
const globalRoot = npmExec(["root", "-g", "--prefix", prefixDir], workingDir);
packageRoot = join(globalRoot, "openclaw");
binaryInvocation = resolveInstalledBinaryCommandInvocation(prefixDir, ["--version"]);
}
const pkg = JSON.parse(
readFileSync(join(packageRoot, "package.json"), "utf8"),
) as InstalledPackageJson;
@ -114,7 +156,6 @@ function main(argv = process.argv.slice(2)): void {
installedVersion: pkg.version?.trim() ?? "",
packageRoot,
});
const binaryInvocation = resolveInstalledBinaryCommandInvocation(prefixDir, ["--version"]);
const installedBinaryVersion = runNpmVerifyCommand(binaryInvocation, workingDir);
if (normalizeInstalledBinaryVersion(installedBinaryVersion) !== resolvedExpectedVersion) {
errors.push(

View file

@ -3,7 +3,7 @@
set -euo pipefail
usage() {
echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack-dry-run|--publish] <package-dir>"
echo "usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] <package-dir>"
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
@ -12,7 +12,7 @@ if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
fi
mode="${1:-}"
if [[ "${mode}" != "--dry-run" && "${mode}" != "--pack-dry-run" && "${mode}" != "--publish" ]]; then
if [[ "${mode}" != "--dry-run" && "${mode}" != "--pack" && "${mode}" != "--pack-dry-run" && "${mode}" != "--publish" ]]; then
usage >&2
exit 2
fi
@ -36,12 +36,16 @@ if [[ "$#" -gt 0 ]]; then
echo "unexpected plugin npm publish argument: $1" >&2
exit 2
fi
if [[ "${mode}" == "--pack" && -z "${OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR:-}" ]]; then
echo "--pack requires OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR" >&2
exit 2
fi
package_name="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.name)' "${package_dir}")"
package_version="$(node -e 'const pkg = require(require("node:path").resolve(process.argv[1], "package.json")); console.log(pkg.version)' "${package_dir}")"
current_beta_version="$(npm view "${package_name}" dist-tags.beta 2>/dev/null || true)"
log() {
if [[ "${mode}" == "--pack-dry-run" ]]; then
if [[ "${mode}" == "--pack" || "${mode}" == "--pack-dry-run" ]]; then
printf '%s\n' "$*" >&2
else
printf '%s\n' "$*"
@ -143,7 +147,7 @@ if [[ "${mirror_auth_requirement}" == "required" && -z "${mirror_auth_token}" ]]
exit 1
fi
if [[ "${mode}" == "--pack-dry-run" ]]; then
if [[ "${mode}" == "--pack" || "${mode}" == "--pack-dry-run" ]]; then
{
printf 'Publish command:'
printf ' %q' "${publish_cmd[@]}"
@ -162,10 +166,18 @@ fi
build_package_runtime
check_package_shrinkwrap
if [[ "${mode}" == "--pack-dry-run" ]]; then
if [[ "${mode}" == "--pack" || "${mode}" == "--pack-dry-run" ]]; then
pack_args=(npm pack --json --ignore-scripts)
if [[ "${mode}" == "--pack-dry-run" ]]; then
pack_args+=(--dry-run)
else
mkdir -p "${OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR}"
pack_output_dir="$(cd "${OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR}" && pwd)"
pack_args+=(--pack-destination "${pack_output_dir}")
fi
OPENCLAW_PLUGIN_NPM_BUNDLE_DEPENDENCIES=1 \
node scripts/lib/plugin-npm-package-manifest.mjs --run "${package_dir}" -- \
npm pack --dry-run --json --ignore-scripts
"${pack_args[@]}"
exit 0
fi

View file

@ -50,16 +50,18 @@ import { resolveNpmRunner } from "./npm-runner.mjs";
import {
collectInstalledPackageErrors,
normalizeInstalledBinaryVersion,
resolveInstalledBinaryCommandInvocation,
resolveInstalledBinaryPath,
} from "./openclaw-npm-postpublish-verify.ts";
import { resolvePnpmRunner } from "./pnpm-runner.mjs";
import { listStaticExtensionAssetOutputs } from "./runtime-postbuild.mjs";
import { sparkleBuildFloorsFromShortVersion, type SparkleBuildFloors } from "./sparkle-build.ts";
import { buildCmdExeCommandLine } from "./windows-cmd-helpers.mjs";
import { buildCmdExeCommandLine, resolveWindowsCmdExePath } from "./windows-cmd-helpers.mjs";
export { collectBundledExtensionManifestErrors } from "./lib/bundled-extension-manifest.ts";
export { packageNameFromSpecifier } from "./lib/plugin-package-dependencies.mjs";
export const RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV =
"OPENCLAW_RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR";
type PackFile = { path: string };
type PackResult = { files?: PackFile[]; filename?: string; unpackedSize?: number };
type ReleaseCheckCommandInvocation = {
@ -362,6 +364,19 @@ function execNpm(
return runReleaseCheckCommand(invocation, options);
}
function execPnpm(
args: string[],
options: {
cwd?: string;
encoding: BufferEncoding;
maxBuffer?: number;
stdio: "inherit" | ["ignore", "pipe", "pipe"];
},
): string {
const invocation = resolvePnpmRunner({ env: process.env, pnpmArgs: args });
return runReleaseCheckCommand(invocation, options);
}
function runPackDry(): PackResult[] {
const raw = execNpm(["pack", "--dry-run", "--json", "--ignore-scripts"], {
encoding: "utf8",
@ -372,15 +387,16 @@ function runPackDry(): PackResult[] {
}
function runPack(packDestination: string): PackResult[] {
const raw = execNpm(
["pack", "--json", "--ignore-scripts", "--pack-destination", packDestination],
const raw = execPnpm(
["--config.ignore-scripts=true", "pack", "--json", "--pack-destination", packDestination],
{
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
maxBuffer: 1024 * 1024 * 100,
},
);
return JSON.parse(raw) as PackResult[];
const parsed = JSON.parse(raw) as PackResult | PackResult[];
return Array.isArray(parsed) ? parsed : [parsed];
}
export function resolvePackedTarballPath(packDestination: string, results: PackResult[]): string {
@ -393,45 +409,120 @@ export function resolvePackedTarballPath(packDestination: string, results: PackR
);
}
const filename = filenames[0];
const filenameBasename = basename(filename);
const resolvedDestination = resolve(packDestination);
const resolvedTarball = resolve(resolvedDestination, filenameBasename);
const isLocalFilename = filename === filenameBasename;
const isAbsolutePathInDestination = resolve(filename) === resolvedTarball;
if (
!filename.endsWith(".tgz") ||
!filenameBasename.endsWith(".tgz") ||
filename.includes("\0") ||
filename !== basename(filename) ||
filename !== win32.basename(filename)
filenameBasename !== win32.basename(filename) ||
(!isLocalFilename && !isAbsolutePathInDestination)
) {
throw new Error(
`release-check: npm pack reported unsafe tarball filename ${JSON.stringify(filename)}.`,
);
}
return resolve(packDestination, filename);
return resolvedTarball;
}
function installPackedTarball(prefixDir: string, tarballPath: string, cwd: string): void {
execNpm(
[
"install",
"-g",
"--prefix",
prefixDir,
"--ignore-scripts",
"--no-audit",
"--no-fund",
tarballPath,
],
{
cwd,
encoding: "utf8",
stdio: "inherit",
},
export function resolveReleaseCheckLocalPackageTarballs(
tarballDir: string | undefined = process.env[RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV],
): string[] {
if (!tarballDir) {
return [];
}
const resolvedDir = resolve(tarballDir);
if (!existsSync(resolvedDir) || !statSync(resolvedDir).isDirectory()) {
throw new Error(
`release-check: ${RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV} must name a directory.`,
);
}
const tarballs = readdirSync(resolvedDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".tgz"))
.map((entry) => resolve(resolvedDir, entry.name))
.toSorted((left, right) => left.localeCompare(right));
if (tarballs.length !== 1) {
throw new Error(
`release-check: ${RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV} contains ${tarballs.length} tarballs; expected exactly one.`,
);
}
return tarballs;
}
export function createPackedTarballInstallArgs(prefixDir: string): string[] {
return ["install", "--prefix", prefixDir, "--ignore-scripts", "--no-audit", "--no-fund"];
}
export function writePackedTarballInstallManifest(
prefixDir: string,
tarballPath: string,
localPackageTarballs: string[],
): void {
if (localPackageTarballs.length > 1) {
throw new Error(
`release-check: packed install accepts at most one @openclaw/ai tarball; found ${localPackageTarballs.length}.`,
);
}
const dependencies: Record<string, string> = {
openclaw: pathToFileURL(tarballPath).href,
};
if (localPackageTarballs[0]) {
dependencies["@openclaw/ai"] = pathToFileURL(localPackageTarballs[0]).href;
}
mkdirSync(prefixDir, { recursive: true });
writeFileSync(
join(prefixDir, "package.json"),
`${JSON.stringify(
{
private: true,
dependencies,
},
null,
2,
)}\n`,
);
}
function resolveGlobalRoot(prefixDir: string, cwd: string): string {
return execNpm(["root", "-g", "--prefix", prefixDir], {
function installPackedTarball(
prefixDir: string,
tarballPath: string,
cwd: string,
localPackageTarballs: string[] = [],
): void {
writePackedTarballInstallManifest(prefixDir, tarballPath, localPackageTarballs);
execNpm(createPackedTarballInstallArgs(prefixDir), {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
}).trim();
stdio: "inherit",
});
}
export function resolvePackedInstalledBinaryPath(
prefixDir: string,
platform: NodeJS.Platform = process.platform,
): string {
return join(
prefixDir,
"node_modules",
".bin",
platform === "win32" ? "openclaw.cmd" : "openclaw",
);
}
function resolvePackedInstalledBinaryCommandInvocation(
prefixDir: string,
args: string[],
): ReleaseCheckCommandInvocation {
const binaryPath = resolvePackedInstalledBinaryPath(prefixDir);
return process.platform === "win32"
? {
command: resolveWindowsCmdExePath(),
args: ["/d", "/s", "/c", buildCmdExeCommandLine(binaryPath, args)],
windowsVerbatimArguments: true,
}
: { command: binaryPath, args };
}
export function createPackedBundledPluginPostinstallEnv(
@ -556,7 +647,7 @@ function verifyPackedInstalledPackage(params: {
prefixDir: string;
tmpRoot: string;
}): void {
const invocation = resolveInstalledBinaryCommandInvocation(params.prefixDir, ["--version"]);
const invocation = resolvePackedInstalledBinaryCommandInvocation(params.prefixDir, ["--version"]);
const installedBinaryVersion = runReleaseCheckCommand(
{
command: invocation.command,
@ -584,7 +675,14 @@ function verifyPackedInstalledPackage(params: {
export function createPackedPluginSdkTypescriptSmokeProject(params: {
consumerDir: string;
packageSpec: string;
aiPackageSpec?: string;
}): void {
const dependencies: Record<string, string> = {
openclaw: params.packageSpec,
};
if (params.aiPackageSpec) {
dependencies["@openclaw/ai"] = params.aiPackageSpec;
}
mkdirSync(join(params.consumerDir, "src"), { recursive: true });
writeFileSync(
join(params.consumerDir, "package.json"),
@ -593,9 +691,7 @@ export function createPackedPluginSdkTypescriptSmokeProject(params: {
name: "openclaw-plugin-sdk-type-smoke",
private: true,
type: "module",
dependencies: {
openclaw: params.packageSpec,
},
dependencies,
},
null,
2,
@ -627,11 +723,16 @@ export function createPackedPluginSdkTypescriptSmokeProject(params: {
);
}
function runPackedPluginSdkTypescriptSmoke(tarballPath: string, tmpRoot: string): void {
function runPackedPluginSdkTypescriptSmoke(
tarballPath: string,
tmpRoot: string,
localPackageTarballs: string[],
): void {
const consumerDir = join(tmpRoot, "plugin-sdk-type-consumer");
createPackedPluginSdkTypescriptSmokeProject({
consumerDir,
packageSpec: `file:${tarballPath}`,
aiPackageSpec: localPackageTarballs[0] ? `file:${localPackageTarballs[0]}` : undefined,
});
execNpm(["install", "--ignore-scripts", "--no-audit", "--no-fund"], {
cwd: consumerDir,
@ -762,7 +863,7 @@ function runPackedCliSmoke(params: {
homeDir: string;
stateDir: string;
}): void {
const binaryPath = resolveInstalledBinaryPath(params.prefixDir);
const binaryPath = resolvePackedInstalledBinaryPath(params.prefixDir);
const env = createPackedCliSmokeEnv(process.env, {
HOME: params.homeDir,
OPENCLAW_STATE_DIR: params.stateDir,
@ -816,9 +917,10 @@ function runPackedBundledChannelEntrySmoke(): void {
const packResults = runPack(packDir);
const tarballPath = resolvePackedTarballPath(packDir, packResults);
const prefixDir = join(tmpRoot, "prefix");
installPackedTarball(prefixDir, tarballPath, tmpRoot);
const localPackageTarballs = resolveReleaseCheckLocalPackageTarballs();
installPackedTarball(prefixDir, tarballPath, tmpRoot, localPackageTarballs);
const packageRoot = join(resolveGlobalRoot(prefixDir, tmpRoot), "openclaw");
const packageRoot = join(prefixDir, "node_modules", "openclaw");
verifyPackedInstalledPackage({
expectedVersion,
packageRoot,
@ -837,7 +939,7 @@ function runPackedBundledChannelEntrySmoke(): void {
runPackedBundledPluginPostinstall(packageRoot);
runPackedBundledPluginActivationSmoke(packageRoot, tmpRoot);
runPackedTaskRegistryControlRuntimeSmoke(packageRoot);
runPackedPluginSdkTypescriptSmoke(tarballPath, tmpRoot);
runPackedPluginSdkTypescriptSmoke(tarballPath, tmpRoot, localPackageTarballs);
runReleaseCheckCommand(
{
command: process.execPath,

View file

@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
openClawNpmPrepublishVerifyUsage,
parseOpenClawNpmPrepublishVerifyArgs,
usesPreparedLocalDependencyInstall,
} from "../scripts/openclaw-npm-prepublish-verify.ts";
describe("parseOpenClawNpmPrepublishVerifyArgs", () => {
@ -47,3 +48,11 @@ describe("parseOpenClawNpmPrepublishVerifyArgs", () => {
).toThrow("Invalid dependency tarball path: --bad");
});
});
describe("usesPreparedLocalDependencyInstall", () => {
it("uses the prepared local project only for the single AI tarball release path", () => {
expect(usesPreparedLocalDependencyInstall(0)).toBe(false);
expect(usesPreparedLocalDependencyInstall(1)).toBe(true);
expect(usesPreparedLocalDependencyInstall(2)).toBe(false);
});
});

View file

@ -835,6 +835,7 @@ describe("createPackedPluginSdkTypescriptSmokeProject", () => {
createPackedPluginSdkTypescriptSmokeProject({
consumerDir,
packageSpec: `file:${packageRoot}`,
aiPackageSpec: "file:/tmp/openclaw-ai.tgz",
});
const packageJson = JSON.parse(readFileSync(join(consumerDir, "package.json"), "utf8")) as {
@ -850,6 +851,7 @@ describe("createPackedPluginSdkTypescriptSmokeProject", () => {
);
expect(packageJson.dependencies?.openclaw).toBe(`file:${packageRoot}`);
expect(packageJson.dependencies?.["@openclaw/ai"]).toBe("file:/tmp/openclaw-ai.tgz");
expect(tsconfig.compilerOptions?.skipLibCheck).toBe(true);
expect(source).toBe(fixtureSource);
expect(source).toContain('"openclaw/plugin-sdk"');
@ -898,6 +900,11 @@ describe("resolvePackedTarballPath", () => {
expect(
resolvePackedTarballPath("/tmp/openclaw-pack", [{ filename: "openclaw-2026.6.17.tgz" }]),
).toBe(resolvePath("/tmp/openclaw-pack", "openclaw-2026.6.17.tgz"));
expect(
resolvePackedTarballPath("/tmp/openclaw-pack", [
{ filename: "/tmp/openclaw-pack/openclaw-2026.6.17.tgz" },
]),
).toBe(resolvePath("/tmp/openclaw-pack", "openclaw-2026.6.17.tgz"));
});
it("rejects path-like npm pack tarball filenames", () => {

View file

@ -209,12 +209,14 @@ describe("extended-stable npm release request", () => {
).toEqual({ extendedStable: false });
});
it("accepts a SHA-only extended-stable preflight bound to the branch tip", () => {
it("accepts a SHA-only extended-stable preflight from an arbitrary workflow ref", () => {
expect(
validateExtendedStableNpmReleaseRequest({
...valid,
preflightOnly: true,
releaseTag: sha,
npmWorkflowRef: "refs/heads/main",
extendedStableBranchSha: "",
}),
).toEqual({
extendedStable: true,
@ -226,8 +228,18 @@ describe("extended-stable npm release request", () => {
...valid,
preflightOnly: true,
releaseTag: "b".repeat(40),
npmWorkflowRef: "refs/heads/dev/preflight-candidate",
extendedStableBranchSha: "",
}),
).toThrow(/must match the checked-out commit/u);
expect(() =>
validateExtendedStableNpmReleaseRequest({
...valid,
preflightOnly: true,
releaseTag: sha,
checkoutSha: "",
}),
).toThrow(/requires the full checked-out commit SHA/u);
expect(() => validateExtendedStableNpmReleaseRequest({ ...valid, releaseTag: sha })).toThrow(
/exact final vYYYY\.M\.P release tag/u,
);

View file

@ -111,6 +111,31 @@ describe("minimal npm extended-stable workflow", () => {
expect(summary.run).toContain("Extended-stable guard bypass: ${BYPASS_EXTENDED_STABLE_GUARD}");
});
it("accepts arbitrary SHA preflight targets and exercises every publishable plugin package", () => {
const parsed = workflow();
const preflight = parsed.jobs?.preflight_openclaw_npm;
const metadata = step(preflight, "Validate release metadata");
expect(metadata.run).toContain('RELEASE_BRANCH_REF="${RELEASE_SHA}"');
expect(metadata.run).not.toContain("Validation-only SHA mode only supports");
const plugins = step(preflight, "Exercise all extended-stable plugin npm packages");
expect(step(preflight, "Verify release contents").env).toMatchObject({
OPENCLAW_RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR:
"${{ steps.ai_runtime_tarballs.outputs.dir }}",
});
expect(plugins.if).toBe("${{ inputs.npm_dist_tag == 'extended-stable' }}");
expect(plugins.env).toMatchObject({
OPENCLAW_PLUGIN_NPM_PUBLISH_TAG: "extended-stable",
});
expect(plugins.run).toContain("--selection-mode all-publishable");
expect(plugins.run).toContain("--npm-dist-tag extended-stable");
expect(plugins.run).toContain("scripts/check-plugin-npm-runtime-builds.mjs");
expect(plugins.run).toContain("scripts/plugin-npm-publish.sh --pack");
expect(plugins.run).toContain("OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR");
expect(plugins.run).not.toContain("--publish");
expect(step(preflight, "Upload extended-stable plugin npm packages")).toBeDefined();
});
it("authenticates exact extended-stable run and Full Validation identities", () => {
const raw = readFileSync(workflowPath, "utf8");
expect(raw).toContain("--json workflowName,headBranch,headSha,event,conclusion,url");

View file

@ -46,7 +46,7 @@ describe("plugin npm publish wrapper", () => {
expect(result.status).toBe(0);
expect(result.stdout.trim()).toBe(
"usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack-dry-run|--publish] <package-dir>",
"usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] <package-dir>",
);
expect(result.stderr).toBe("");
});
@ -57,10 +57,18 @@ describe("plugin npm publish wrapper", () => {
expect(result.status).toBe(2);
expect(result.stdout).toBe("");
expect(result.stderr.trim()).toBe(
"usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack-dry-run|--publish] <package-dir>",
"usage: bash scripts/plugin-npm-publish.sh [--dry-run|--pack|--pack-dry-run|--publish] <package-dir>",
);
});
it("requires an explicit artifact directory for real pack mode", () => {
const result = runPluginPublishWrapper(["--pack", "extensions/telegram"]);
expect(result.status).toBe(2);
expect(result.stdout).toBe("");
expect(result.stderr.trim()).toBe("--pack requires OPENCLAW_PLUGIN_NPM_PACK_OUTPUT_DIR");
});
it("rejects option-like package dirs before package checks", () => {
const result = runPluginPublishWrapper(["--dry-run", "--wat"]);

View file

@ -1,9 +1,15 @@
// Release Check tests cover release check script behavior.
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { writePackedBundledPluginActivationConfig } from "../../scripts/release-check.ts";
import {
createPackedTarballInstallArgs,
RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV,
resolveReleaseCheckLocalPackageTarballs,
writePackedTarballInstallManifest,
writePackedBundledPluginActivationConfig,
} from "../../scripts/release-check.ts";
function requirePluginEntries(config: { plugins?: { entries?: Record<string, unknown> } }) {
if (!config.plugins?.entries) {
@ -13,6 +19,83 @@ function requirePluginEntries(config: { plugins?: { entries?: Record<string, unk
}
describe("release-check", () => {
it("installs the packed core and local sibling package tarballs together", () => {
expect(createPackedTarballInstallArgs("/tmp/prefix")).toEqual([
"install",
"--prefix",
"/tmp/prefix",
"--ignore-scripts",
"--no-audit",
"--no-fund",
]);
});
it("resolves exactly one prepacked local dependency tarball", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-tarball-test-"));
try {
writeFileSync(join(root, "openclaw-ai-2026.6.33.tgz"), "fixture");
writeFileSync(join(root, "SHA256SUMS"), "fixture");
expect(resolveReleaseCheckLocalPackageTarballs(root)).toEqual([
join(root, "openclaw-ai-2026.6.33.tgz"),
]);
expect(resolveReleaseCheckLocalPackageTarballs(undefined)).toEqual([]);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("writes an explicit local project for unpublished core and AI tarballs", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-install-test-"));
try {
writePackedTarballInstallManifest(root, "/tmp/openclaw.tgz", ["/tmp/openclaw-ai.tgz"]);
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
private?: boolean;
};
expect(manifest.private).toBe(true);
expect(manifest.dependencies).toEqual({
"@openclaw/ai": "file:///tmp/openclaw-ai.tgz",
openclaw: "file:///tmp/openclaw.tgz",
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("preserves the no-env local release check path", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-install-test-"));
try {
writePackedTarballInstallManifest(root, "/tmp/openclaw.tgz", []);
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
private?: boolean;
};
expect(manifest.private).toBe(true);
expect(manifest.dependencies).toEqual({
openclaw: "file:///tmp/openclaw.tgz",
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("rejects missing, empty, or ambiguous local dependency tarball directories", () => {
const root = mkdtempSync(join(tmpdir(), "openclaw-release-check-tarball-test-"));
try {
expect(() => resolveReleaseCheckLocalPackageTarballs(join(root, "missing"))).toThrow(
RELEASE_CHECK_LOCAL_PACKAGE_TARBALL_DIR_ENV,
);
const empty = join(root, "empty");
mkdirSync(empty);
expect(() => resolveReleaseCheckLocalPackageTarballs(empty)).toThrow("contains 0 tarballs");
writeFileSync(join(empty, "one.tgz"), "fixture");
writeFileSync(join(empty, "two.tgz"), "fixture");
expect(() => resolveReleaseCheckLocalPackageTarballs(empty)).toThrow("contains 2 tarballs");
} finally {
rmSync(root, { recursive: true, force: true });
}
});
it("seeds packaged activation smoke with an included channel plugin", () => {
const homeDir = mkdtempSync(join(tmpdir(), "openclaw-release-check-test-"));
try {