From cd89892b1f4aebddcde526c25634d5b21681b77b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Apr 2026 13:10:42 +0100 Subject: [PATCH] fix(release): keep private QA bundles out of npm pack --- extensions/qa-lab/cli.ts | 2 +- extensions/qa-lab/src/cli.ts | 5 +++++ extensions/qa-lab/src/scenario-catalog.ts | 4 ++++ package.json | 2 ++ scripts/openclaw-npm-release-check.ts | 25 ++++++++++++++++++++--- src/cli/program/register.subclis-core.ts | 8 +++++++- src/cli/program/register.subclis.test.ts | 17 ++++++++++++--- src/cli/program/subcli-descriptors.ts | 13 ++++++++++-- src/plugin-sdk/qa-lab.ts | 3 +++ test/openclaw-npm-release-check.test.ts | 12 +++++++++++ 10 files changed, 81 insertions(+), 10 deletions(-) diff --git a/extensions/qa-lab/cli.ts b/extensions/qa-lab/cli.ts index 377b412e24e..417b26d38bd 100644 --- a/extensions/qa-lab/cli.ts +++ b/extensions/qa-lab/cli.ts @@ -1 +1 @@ -export { registerQaLabCli } from "./src/cli.js"; +export { isQaLabCliAvailable, registerQaLabCli } from "./src/cli.js"; diff --git a/extensions/qa-lab/src/cli.ts b/extensions/qa-lab/src/cli.ts index b0e40ce649b..b915be9dc67 100644 --- a/extensions/qa-lab/src/cli.ts +++ b/extensions/qa-lab/src/cli.ts @@ -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") diff --git a/extensions/qa-lab/src/scenario-catalog.ts b/extensions/qa-lab/src/scenario-catalog.ts index 09060fdd25b..32dbb893b5b 100644 --- a/extensions/qa-lab/src/scenario-catalog.ts +++ b/extensions/qa-lab/src/scenario-catalog.ts @@ -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) { diff --git a/package.json b/package.json index 8b615a2e7c5..7bd63288bed 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/openclaw-npm-release-check.ts b/scripts/openclaw-npm-release-check.ts index 77fb6d529d8..a4d24322793 100644 --- a/scripts/openclaw-npm-release-check.ts +++ b/scripts/openclaw-npm-release-check.ts @@ -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[] { 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)); } diff --git a/src/cli/program/register.subclis-core.ts b/src/cli/program/register.subclis-core.ts index 8d173f17e85..031c00ed15c 100644 --- a/src/cli/program/register.subclis-core.ts +++ b/src/cli/program/register.subclis-core.ts @@ -216,7 +216,13 @@ const entrySpecs: readonly CommandGroupDescriptorSpec[] = [ ]; 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 { diff --git a/src/cli/program/register.subclis.test.ts b/src/cli/program/register.subclis.test.ts index 00650b4ffe7..54572599e71 100644 --- a/src/cli/program/register.subclis.test.ts +++ b/src/cli/program/register.subclis.test.ts @@ -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"); diff --git a/src/cli/program/subcli-descriptors.ts b/src/cli/program/subcli-descriptors.ts index f95ce47c5d5..202f885edb5 100644 --- a/src/cli/program/subcli-descriptors.ts +++ b/src/cli/program/subcli-descriptors.ts @@ -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 { - 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"); } diff --git a/src/plugin-sdk/qa-lab.ts b/src/plugin-sdk/qa-lab.ts index 73e76908d22..314e13d47fc 100644 --- a/src/plugin-sdk/qa-lab.ts +++ b/src/plugin-sdk/qa-lab.ts @@ -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"]; diff --git a/test/openclaw-npm-release-check.test.ts b/test/openclaw-npm-release-check.test.ts index f690108ff37..7159a3d6cf5 100644 --- a/test/openclaw-npm-release-check.test.ts +++ b/test/openclaw-npm-release-check.test.ts @@ -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", () => {