fix(release): keep private QA bundles out of npm pack

This commit is contained in:
Peter Steinberger
2026-04-11 13:10:42 +01:00
parent a733e92c45
commit cd89892b1f
10 changed files with 81 additions and 10 deletions

View File

@@ -1 +1 @@
export { registerQaLabCli } from "./src/cli.js";
export { isQaLabCliAvailable, registerQaLabCli } from "./src/cli.js";

View File

@@ -2,6 +2,7 @@ import type { Command } from "commander";
import { collectString } from "./cli-options.js";
import { LIVE_TRANSPORT_QA_CLI_REGISTRATIONS } from "./live-transports/cli.js";
import type { QaProviderModeInput } from "./run-config.js";
import { hasQaScenarioPack } from "./scenario-catalog.js";
type QaLabCliRuntime = typeof import("./cli.runtime.js");
@@ -125,6 +126,10 @@ async function runQaMockOpenAi(opts: { host?: string; port?: number }) {
await runtime.runQaMockOpenAiCommand(opts);
}
export function isQaLabCliAvailable(): boolean {
return hasQaScenarioPack();
}
export function registerQaLabCli(program: Command) {
const qa = program
.command("qa")

View File

@@ -181,6 +181,10 @@ function resolveRepoPath(relativePath: string, kind: "file" | "directory" = "fil
return null;
}
export function hasQaScenarioPack(): boolean {
return resolveRepoPath(QA_SCENARIO_PACK_INDEX_PATH, "file") !== null;
}
function readTextFile(relativePath: string): string {
const resolved = resolveRepoPath(relativePath, "file");
if (!resolved) {

View File

@@ -29,6 +29,8 @@
"dist/",
"!dist/**/*.map",
"!dist/plugin-sdk/.tsbuildinfo",
"!dist/extensions/qa-channel/**",
"!dist/extensions/qa-lab/**",
"docs/",
"!docs/.generated/**",
"!docs/.i18n/zh-CN.tm.jsonl",

View File

@@ -56,7 +56,23 @@ const EXPECTED_REPOSITORY_URL = "https://github.com/openclaw/openclaw";
const MAX_CALVER_DISTANCE_DAYS = 2;
const REQUIRED_PACKED_PATHS = ["dist/control-ui/index.html"];
const CONTROL_UI_ASSET_PREFIX = "dist/control-ui/assets/";
const FORBIDDEN_PACKED_PATH_PREFIXES = ["docs/.generated/"] as const;
const FORBIDDEN_PACKED_PATH_RULES = [
{
prefix: "docs/.generated/",
describe: (packedPath: string) =>
`npm package must not include generated docs artifact "${packedPath}".`,
},
{
prefix: "dist/extensions/qa-channel/",
describe: (packedPath: string) =>
`npm package must not include private QA channel artifact "${packedPath}".`,
},
{
prefix: "dist/extensions/qa-lab/",
describe: (packedPath: string) =>
`npm package must not include private QA lab artifact "${packedPath}".`,
},
] as const;
const NPM_PACK_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
const skipPackValidationEnv = "OPENCLAW_NPM_RELEASE_SKIP_PACK_CHECK";
@@ -447,10 +463,13 @@ function collectPackedTarballErrors(): string[] {
export function collectForbiddenPackedPathErrors(paths: Iterable<string>): string[] {
const errors: string[] = [];
for (const packedPath of paths) {
if (!FORBIDDEN_PACKED_PATH_PREFIXES.some((prefix) => packedPath.startsWith(prefix))) {
const matchedRule = FORBIDDEN_PACKED_PATH_RULES.find((rule) =>
packedPath.startsWith(rule.prefix),
);
if (!matchedRule) {
continue;
}
errors.push(`npm package must not include generated docs artifact "${packedPath}".`);
errors.push(matchedRule.describe(packedPath));
}
return errors.toSorted((left, right) => left.localeCompare(right));
}

View File

@@ -216,7 +216,13 @@ const entrySpecs: readonly CommandGroupDescriptorSpec<SubCliRegistrar>[] = [
];
function resolveSubCliCommandGroups(): CommandGroupEntry[] {
return buildCommandGroupEntries(getSubCliEntryDescriptors(), entrySpecs, (register) => register);
const descriptors = getSubCliEntryDescriptors();
const descriptorNames = new Set(descriptors.map((descriptor) => descriptor.name));
return buildCommandGroupEntries(
descriptors,
entrySpecs.filter((spec) => spec.commandNames.every((name) => descriptorNames.has(name))),
(register) => register,
);
}
export function getSubCliEntries(): ReadonlyArray<SubCliDescriptor> {

View File

@@ -19,8 +19,9 @@ const { nodesAction, registerNodesCli } = vi.hoisted(() => {
return { nodesAction: action, registerNodesCli: register };
});
const { registerQaCli } = vi.hoisted(() => ({
registerQaCli: vi.fn((program: Command) => {
const { isQaLabCliAvailable, registerQaLabCli } = vi.hoisted(() => ({
isQaLabCliAvailable: vi.fn(() => true),
registerQaLabCli: vi.fn((program: Command) => {
const qa = program.command("qa");
qa.command("run").action(() => undefined);
}),
@@ -36,8 +37,8 @@ const { inferAction, registerCapabilityCli } = vi.hoisted(() => {
vi.mock("../acp-cli.js", () => ({ registerAcpCli }));
vi.mock("../nodes-cli.js", () => ({ registerNodesCli }));
vi.mock("../qa-cli.js", () => ({ registerQaCli }));
vi.mock("../capability-cli.js", () => ({ registerCapabilityCli }));
vi.mock("../../plugin-sdk/qa-lab.js", () => ({ isQaLabCliAvailable, registerQaLabCli }));
describe("registerSubCliCommands", () => {
const originalArgv = process.argv;
@@ -63,6 +64,8 @@ describe("registerSubCliCommands", () => {
acpAction.mockClear();
registerNodesCli.mockClear();
nodesAction.mockClear();
isQaLabCliAvailable.mockReset().mockReturnValue(true);
registerQaLabCli.mockClear();
registerCapabilityCli.mockClear();
inferAction.mockClear();
});
@@ -98,6 +101,14 @@ describe("registerSubCliCommands", () => {
expect(registerAcpCli).not.toHaveBeenCalled();
});
it("omits the qa placeholder when the private qa bundle is unavailable", () => {
isQaLabCliAvailable.mockReturnValue(false);
const program = createRegisteredProgram(["node", "openclaw"]);
expect(program.commands.map((cmd) => cmd.name())).not.toContain("qa");
});
it("re-parses argv for lazy subcommands", async () => {
const program = createRegisteredProgram(["node", "openclaw", "nodes", "list"], "openclaw");

View File

@@ -1,3 +1,4 @@
import { isQaLabCliAvailable } from "../../plugin-sdk/qa-lab.js";
import { defineCommandDescriptorCatalog } from "./command-descriptor-utils.js";
import type { NamedCommandDescriptor } from "./command-group-descriptors.js";
@@ -157,9 +158,17 @@ const subCliCommandCatalog = defineCommandDescriptorCatalog([
export const SUB_CLI_DESCRIPTORS = subCliCommandCatalog.descriptors;
export function getSubCliEntries(): ReadonlyArray<SubCliDescriptor> {
return subCliCommandCatalog.getDescriptors();
const descriptors = subCliCommandCatalog.getDescriptors();
if (isQaLabCliAvailable()) {
return descriptors;
}
return descriptors.filter((descriptor) => descriptor.name !== "qa");
}
export function getSubCliCommandsWithSubcommands(): string[] {
return subCliCommandCatalog.getCommandsWithSubcommands();
const commands = subCliCommandCatalog.getCommandsWithSubcommands();
if (isQaLabCliAvailable()) {
return commands;
}
return commands.filter((command) => command !== "qa");
}

View File

@@ -11,3 +11,6 @@ function loadFacadeModule(): FacadeModule {
export const registerQaLabCli: FacadeModule["registerQaLabCli"] = ((...args) =>
loadFacadeModule().registerQaLabCli(...args)) as FacadeModule["registerQaLabCli"];
export const isQaLabCliAvailable: FacadeModule["isQaLabCliAvailable"] = (() =>
loadFacadeModule().isQaLabCliAvailable()) as FacadeModule["isQaLabCliAvailable"];

View File

@@ -308,6 +308,18 @@ describe("collectForbiddenPackedPathErrors", () => {
'npm package must not include generated docs artifact "docs/.generated/config-baseline.plugin.json".',
]);
});
it("rejects private qa artifacts in npm pack output", () => {
expect(
collectForbiddenPackedPathErrors([
"dist/extensions/qa-channel/package.json",
"dist/extensions/qa-lab/src/cli.js",
]),
).toEqual([
'npm package must not include private QA channel artifact "dist/extensions/qa-channel/package.json".',
'npm package must not include private QA lab artifact "dist/extensions/qa-lab/src/cli.js".',
]);
});
});
describe("collectReleaseTagErrors", () => {