From c638f2beda87a3309daa19766e4d17b417f2c71f Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 23 Jun 2026 14:30:23 +0200 Subject: [PATCH] fix(release): reject duplicate candidate checklist options --- scripts/release-candidate-checklist.mjs | 44 +++++++++++-------- .../release-candidate-checklist.test.ts | 35 +++++++++++++++ 2 files changed, 61 insertions(+), 18 deletions(-) diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index 804c3bcefab1..60e4d5536da4 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -87,64 +87,72 @@ export function parseArgs(argv) { windowsNodeInstallerDigests: "", outputDir: "", }; + const seen = new Set(); + const setOnce = (flag, key, value) => { + if (seen.has(flag)) { + throw new Error(`${flag} was provided more than once`); + } + seen.add(flag); + options[key] = value; + }; parseArgv: for (let index = 0; index < args.length; index += 1) { const arg = args[index]; switch (arg) { case "--": break parseArgv; case "--tag": - options.tag = requireValue(args, ++index, arg); + setOnce(arg, "tag", requireValue(args, ++index, arg)); break; case "--workflow-ref": - options.workflowRef = requireValue(args, ++index, arg); + setOnce(arg, "workflowRef", requireValue(args, ++index, arg)); break; case "--repo": - options.repo = requireValue(args, ++index, arg); + setOnce(arg, "repo", requireValue(args, ++index, arg)); break; case "--full-release-run": - options.fullReleaseRunId = requireValue(args, ++index, arg); + setOnce(arg, "fullReleaseRunId", requireValue(args, ++index, arg)); break; case "--npm-preflight-run": - options.npmPreflightRunId = requireValue(args, ++index, arg); + setOnce(arg, "npmPreflightRunId", requireValue(args, ++index, arg)); break; case "--windows-node-tag": - options.windowsNodeTag = requireValue(args, ++index, arg); + setOnce(arg, "windowsNodeTag", requireValue(args, ++index, arg)); break; case "--skip-dispatch": - options.skipDispatch = true; + setOnce(arg, "skipDispatch", true); break; case "--skip-local-generated-check": - options.skipLocalGeneratedCheck = true; + setOnce(arg, "skipLocalGeneratedCheck", true); break; case "--skip-parallels": - options.skipParallels = true; + setOnce(arg, "skipParallels", true); break; case "--skip-telegram": - options.skipTelegram = true; + setOnce(arg, "skipTelegram", true); break; case "--telegram-provider-mode": - options.telegramProviderMode = requireValue(args, ++index, arg); + setOnce(arg, "telegramProviderMode", requireValue(args, ++index, arg)); break; case "--provider": - options.provider = requireValue(args, ++index, arg); + setOnce(arg, "provider", requireValue(args, ++index, arg)); break; case "--mode": - options.mode = requireValue(args, ++index, arg); + setOnce(arg, "mode", requireValue(args, ++index, arg)); break; case "--release-profile": - options.releaseProfile = requireValue(args, ++index, arg); + setOnce(arg, "releaseProfile", requireValue(args, ++index, arg)); break; case "--npm-dist-tag": - options.npmDistTag = requireValue(args, ++index, arg); + setOnce(arg, "npmDistTag", requireValue(args, ++index, arg)); break; case "--plugin-publish-scope": - options.pluginPublishScope = requireValue(args, ++index, arg); + setOnce(arg, "pluginPublishScope", requireValue(args, ++index, arg)); break; case "--plugins": - options.plugins = requireValue(args, ++index, arg); + setOnce(arg, "plugins", requireValue(args, ++index, arg)); break; case "--output-dir": - options.outputDir = requireValue(args, ++index, arg); + setOnce(arg, "outputDir", requireValue(args, ++index, arg)); break; case "-h": case "--help": diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index 5a13bcfc9b32..365389688fe0 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -79,6 +79,41 @@ describe("release candidate checklist", () => { ); }); + it("rejects duplicate release candidate CLI options", () => { + const requiredArgs = ["--tag", "v2026.5.14-beta.3"]; + const duplicateOption = ( + flag: string, + firstValue: string, + secondValue: string, + prefix = requiredArgs, + ): [string, string[]] => [flag, [...prefix, flag, firstValue, flag, secondValue]]; + const duplicateFlag = (flag: string): [string, string[]] => [flag, [...requiredArgs, flag, flag]]; + const duplicateCases = [ + duplicateOption("--tag", "v2026.5.14-beta.3", "v2026.5.14-beta.4", []), + duplicateOption("--workflow-ref", "release/a", "release/b"), + duplicateOption("--repo", "openclaw/openclaw", "fork/openclaw"), + duplicateOption("--full-release-run", "111", "222"), + duplicateOption("--npm-preflight-run", "111", "222"), + duplicateOption("--windows-node-tag", "v0.6.3", "v0.6.4"), + duplicateFlag("--skip-dispatch"), + duplicateFlag("--skip-local-generated-check"), + duplicateFlag("--skip-parallels"), + duplicateFlag("--skip-telegram"), + duplicateOption("--telegram-provider-mode", "mock-openai", "live-frontier"), + duplicateOption("--provider", "blacksmith-testbox", "crabbox"), + duplicateOption("--mode", "fresh", "upgrade"), + duplicateOption("--release-profile", "beta", "stable"), + duplicateOption("--npm-dist-tag", "beta", "latest"), + duplicateOption("--plugin-publish-scope", "all-publishable", "selected"), + duplicateOption("--plugins", "telegram", "discord"), + duplicateOption("--output-dir", ".artifacts/a", ".artifacts/b"), + ] satisfies Array<[string, string[]]>; + + for (const [flag, args] of duplicateCases) { + expect(() => parseArgs(args), flag).toThrow(`${flag} was provided more than once`); + } + }); + it("requires stable validation evidence to include soak and blocking performance", () => { const stableManifest = { workflowName: "Full Release Validation",