From 512141745597979c57adf9d401750f55ba305863 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 4 Jun 2026 09:09:03 +0200 Subject: [PATCH 1/2] ci(release): Guard publishable package config Discover publishable packages from manifests so release validation and version bumping cannot silently miss a new package. Validate Craft targets, pack steps, artifact selectors, and action bundle wiring against that package set. Co-Authored-By: OpenAI Codex --- README.md | 5 + scripts/bump-release-versions.mjs | 18 +- scripts/check-release-config.mjs | 369 ++++++++++++++++++++------ scripts/check-release-config.test.mjs | 212 +++++++++++++++ scripts/release-packages.mjs | 64 +++++ 5 files changed, 579 insertions(+), 89 deletions(-) create mode 100644 scripts/check-release-config.test.mjs create mode 100644 scripts/release-packages.mjs diff --git a/README.md b/README.md index d067346..00a3921 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,11 @@ then moves `vX.Y.Z` and the stable `vX` tag to the bundled action commit. The Craft GitHub target creates the release but is filtered away from package artifacts so it does not upload npm package contents as release assets. +Release config validation treats every non-private `packages/*/package.json` as +publishable. `pnpm release:check` verifies that `.craft.yml`, the post-merge +pack step, Craft artifact selectors, package version bumping, and the bundled +action tag workflow all cover that manifest-derived package set. + ## Example The `apps/demo-pi` app shows the intended explicit-run flow: diff --git a/scripts/bump-release-versions.mjs b/scripts/bump-release-versions.mjs index d77a5f7..60e5088 100644 --- a/scripts/bump-release-versions.mjs +++ b/scripts/bump-release-versions.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; +import { collectPublishablePackages } from "./release-packages.mjs"; const newVersion = process.argv[2]; if (!newVersion) { @@ -8,19 +9,18 @@ if (!newVersion) { process.exit(1); } -const files = [ - "packages/vitest-evals/package.json", - "packages/harness-ai-sdk/package.json", - "packages/harness-openai-agents/package.json", - "packages/harness-pi-ai/package.json", - "packages/github-reporter/package.json", -]; +const packages = collectPublishablePackages(); -for (const relativePath of files) { +if (packages.length === 0) { + console.error("No publishable packages found under packages/."); + process.exit(1); +} + +for (const { relativePath } of packages) { const absolutePath = path.resolve(process.cwd(), relativePath); const pkg = JSON.parse(fs.readFileSync(absolutePath, "utf8")); pkg.version = newVersion; fs.writeFileSync(absolutePath, `${JSON.stringify(pkg, null, 2)}\n`); } -console.log(`Updated ${files.length} package versions to ${newVersion}`); +console.log(`Updated ${packages.length} package versions to ${newVersion}`); diff --git a/scripts/check-release-config.mjs b/scripts/check-release-config.mjs index 148cbd7..7925561 100644 --- a/scripts/check-release-config.mjs +++ b/scripts/check-release-config.mjs @@ -1,145 +1,354 @@ #!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + collectPublishablePackages, + packageTarballName, +} from "./release-packages.mjs"; -const root = process.cwd(); +const ACTION_BUNDLE_MAIN = "github-reporter/dist/action/index.js"; -function readFile(relativePath) { +function readFile(root, relativePath) { return fs.readFileSync(path.join(root, relativePath), "utf8"); } +function readJson(root, relativePath) { + return JSON.parse(readFile(root, relativePath)); +} + function collectMatches(text, pattern) { return [ ...new Set([...text.matchAll(pattern)].map((match) => match[1])), ].sort(); } -function collectCraftPackages() { - return collectMatches(readFile(".craft.yml"), /^\s*id:\s*"([^"]+)"/gm); +function parseScalar(value) { + const trimmed = value.trim(); + const quoted = trimmed.match(/^"([^"]*)"$/); + return quoted ? quoted[1] : trimmed; } -function collectCraftTargets() { - const craftConfig = readFile(".craft.yml"); +function collectCraftTargets(root) { + const craftConfig = readFile(root, ".craft.yml"); return [ ...craftConfig.matchAll( /^\s*-\s*name:\s*([^\s#]+)\b[\s\S]*?(?=^\s*-\s*name:|(?![\s\S]))/gm, ), - ].map((match) => ({ - name: match[1], - block: match[0], - })); + ].map((match) => { + const block = match[0]; + const id = block.match(/^\s*id:\s*(.+)$/m)?.[1]; + const access = block.match(/^\s*access:\s*(.+)$/m)?.[1]; + const includeNames = block.match(/^\s*includeNames:\s*(.+)$/m)?.[1]; + + return { + access: access ? parseScalar(access) : undefined, + block, + id: id ? parseScalar(id) : undefined, + includeNames: includeNames ? parseScalar(includeNames) : undefined, + name: parseScalar(match[1]), + }; + }); +} + +function parseRegexLiteral(value, targetDescription) { + if (!value) { + throw new Error(`${targetDescription} must define includeNames.`); + } + + const match = value.match(/^\/((?:\\.|[^/])*)\/([a-z]*)$/i); + if (!match) { + throw new Error( + `${targetDescription} includeNames must be a JavaScript regex literal.`, + ); + } + + return new RegExp(match[1], match[2]); +} + +function collectCraftPackages(root) { + return collectCraftTargets(root) + .filter((target) => target.name === "npm") + .map((target) => target.id) + .filter(Boolean) + .sort(); +} + +function collectPackPackages(root) { + return collectMatches( + readFile(root, ".github/workflows/merge-jobs.yml"), + /pnpm --filter ([^\s]+) pack --pack-destination artifacts/g, + ); } -function assertGitHubTargetConfig() { - const targets = collectCraftTargets(); +function describeMismatch(expected, actual) { + const missing = expected.filter((entry) => !actual.includes(entry)); + const extra = actual.filter((entry) => !expected.includes(entry)); + + if (missing.length === 0 && extra.length === 0) { + return null; + } + + return { missing, extra }; +} + +function formatMismatch(label, mismatch) { + const lines = [`Release config mismatch in ${label}:`]; + + if (mismatch.missing.length > 0) { + lines.push(` Missing: ${mismatch.missing.join(", ")}`); + } + + if (mismatch.extra.length > 0) { + lines.push(` Extra: ${mismatch.extra.join(", ")}`); + } + + return lines.join("\n"); +} + +function assertGitHubTargetConfig(targets, errors) { const githubTargets = targets.filter((target) => target.name === "github"); if (githubTargets.length !== 1) { - console.error( + errors.push( "Release config check failed: .craft.yml must define exactly one github target for root action tags.", ); - process.exit(1); + return; } const [githubTarget] = githubTargets; if (targets.at(-1) !== githubTarget) { - console.error( + errors.push( "Release config check failed: the github target must be the final target so npm publishes before the public GitHub release and action tags.", ); - process.exit(1); } if (!/^\s*includeNames:\s*\/\^\$\/\s*$/m.test(githubTarget.block)) { - console.error( + errors.push( "Release config check failed: the github target must keep includeNames: /^$/ so package artifacts are not uploaded as GitHub release assets.", ); - process.exit(1); } } -function collectBumpPackages() { - const packageFiles = collectMatches( - readFile("scripts/bump-release-versions.mjs"), - /"(packages\/[^"]+\/package\.json)"/g, +function assertCraftNpmTargets({ packages, targets }, errors) { + const byName = new Map( + packages.map((packageInfo) => [packageInfo.name, packageInfo]), ); + const npmTargets = targets.filter((target) => target.name === "npm"); + const seenIds = new Set(); + const tarballs = packages.map((packageInfo) => [ + packageInfo.name, + packageTarballName(packageInfo), + ]); - return packageFiles - .map((relativePath) => JSON.parse(readFile(relativePath)).name) - .sort(); -} + for (const target of npmTargets) { + const description = `Craft npm target${target.id ? ` ${target.id}` : ""}`; -function collectPackPackages() { - return collectMatches( - readFile(".github/workflows/merge-jobs.yml"), - /pnpm --filter ([^\s]+) pack --pack-destination artifacts/g, - ); + if (!target.id) { + errors.push(`${description} must define an id.`); + continue; + } + + if (seenIds.has(target.id)) { + errors.push( + `Release config check failed: duplicate Craft npm target ${target.id}.`, + ); + } + seenIds.add(target.id); + + const packageInfo = byName.get(target.id); + if (!packageInfo) { + continue; + } + + const expectedAccess = packageInfo.packageJson.publishConfig?.access; + if (expectedAccess && target.access !== expectedAccess) { + errors.push( + `Release config check failed: Craft target ${target.id} must set access: ${expectedAccess}.`, + ); + } + + let includeNames; + try { + includeNames = parseRegexLiteral(target.includeNames, description); + } catch (error) { + errors.push(`Release config check failed: ${error.message}`); + continue; + } + + const ownTarball = packageTarballName(packageInfo); + + if (!includeNames.test(ownTarball)) { + errors.push( + `Release config check failed: Craft target ${target.id} includeNames does not match ${ownTarball}.`, + ); + } + + for (const [otherPackageName, otherTarball] of tarballs) { + if (otherPackageName === target.id) { + continue; + } + + if (includeNames.test(otherTarball)) { + errors.push( + `Release config check failed: Craft target ${target.id} includeNames also matches ${otherPackageName} artifact ${otherTarball}.`, + ); + } + } + } } -function describeMismatch(expected, actual) { - const missing = expected.filter((entry) => !actual.includes(entry)); - const extra = actual.filter((entry) => !expected.includes(entry)); +function assertVersionBumpConfig(root, errors) { + const craftConfig = readFile(root, ".craft.yml"); + if ( + !/^preReleaseCommand:\s*bash scripts\/craft-pre-release\.sh\s*$/m.test( + craftConfig, + ) + ) { + errors.push( + "Release config check failed: .craft.yml must run scripts/craft-pre-release.sh before release.", + ); + } - if (missing.length === 0 && extra.length === 0) { - return null; + const preReleaseScript = readFile(root, "scripts/craft-pre-release.sh"); + if ( + !preReleaseScript.includes( + 'node scripts/bump-release-versions.mjs "${NEW_VERSION}"', + ) + ) { + errors.push( + "Release config check failed: craft-pre-release.sh must pass NEW_VERSION to bump-release-versions.mjs.", + ); } - return { missing, extra }; + const bumpScript = readFile(root, "scripts/bump-release-versions.mjs"); + if ( + !bumpScript.includes("collectPublishablePackages") || + !bumpScript.includes("./release-packages.mjs") + ) { + errors.push( + "Release config check failed: bump-release-versions.mjs must discover publishable packages from release-packages.mjs.", + ); + } } -const sources = [ - { - label: ".craft.yml", - packages: collectCraftPackages(), - }, - { - label: "scripts/bump-release-versions.mjs", - packages: collectBumpPackages(), - }, - { - label: ".github/workflows/merge-jobs.yml", - packages: collectPackPackages(), - }, -]; - -const [expectedSource, ...otherSources] = sources; - -assertGitHubTargetConfig(); - -if (expectedSource.packages.length === 0) { - console.error( - "Release config check failed: .craft.yml does not define any publish targets.", +function assertActionBundleConfig(root, errors) { + const rootPackage = readJson(root, "package.json"); + if ( + rootPackage.scripts?.["build:action"] !== + "pnpm --filter @vitest-evals/github-reporter run build:action" + ) { + errors.push( + "Release config check failed: root build:action must build @vitest-evals/github-reporter.", + ); + } + + const action = readFile(root, "action.yml"); + const actionMain = action.match(/^\s*main:\s*(.+)$/m)?.[1]; + if (actionMain ? parseScalar(actionMain) !== ACTION_BUNDLE_MAIN : true) { + errors.push( + `Release config check failed: action.yml must run ${ACTION_BUNDLE_MAIN}.`, + ); + } + + const actionConfig = readFile( + root, + "packages/github-reporter/tsup.action.config.ts", + ); + const outDir = actionConfig.match(/outDir:\s*"([^"]+)"/)?.[1]; + if (!outDir) { + errors.push( + "Release config check failed: packages/github-reporter/tsup.action.config.ts must define outDir.", + ); + } else { + const resolvedOutDir = path + .relative(root, path.resolve(root, "packages/github-reporter", outDir)) + .split(path.sep) + .join("/"); + const expectedOutDir = path.posix.dirname(ACTION_BUNDLE_MAIN); + + if (resolvedOutDir !== expectedOutDir) { + errors.push( + `Release config check failed: action bundle outDir resolves to ${resolvedOutDir}, expected ${expectedOutDir}.`, + ); + } + } + + const updateActionTag = readFile( + root, + ".github/workflows/update-action-tag.yml", ); - process.exit(1); + if (!updateActionTag.includes(`git add -f ${ACTION_BUNDLE_MAIN}`)) { + errors.push( + `Release config check failed: update-action-tag.yml must commit ${ACTION_BUNDLE_MAIN}.`, + ); + } + + const releaseWorkflow = readFile(root, ".github/workflows/release.yml"); + if (!releaseWorkflow.includes(`${ACTION_BUNDLE_MAIN}`)) { + errors.push( + `Release config check failed: release.yml must verify ${ACTION_BUNDLE_MAIN} on bundled action tags.`, + ); + } } -let hasMismatch = false; +export function checkReleaseConfig(root = process.cwd()) { + const packages = collectPublishablePackages(root); + const expectedPackages = packages.map((packageInfo) => packageInfo.name); + const targets = collectCraftTargets(root); + const errors = []; -for (const source of otherSources) { - const mismatch = describeMismatch(expectedSource.packages, source.packages); + assertGitHubTargetConfig(targets, errors); - if (!mismatch) { - continue; + if (expectedPackages.length === 0) { + errors.push( + "Release config check failed: no publishable packages found under packages/.", + ); } - hasMismatch = true; - console.error(`Release config mismatch in ${source.label}:`); + const sources = [ + { + label: ".craft.yml", + packages: collectCraftPackages(root), + }, + { + label: ".github/workflows/merge-jobs.yml", + packages: collectPackPackages(root), + }, + ]; - if (mismatch.missing.length > 0) { - console.error(` Missing: ${mismatch.missing.join(", ")}`); + for (const source of sources) { + const mismatch = describeMismatch(expectedPackages, source.packages); + + if (mismatch) { + errors.push(formatMismatch(source.label, mismatch)); + } } - if (mismatch.extra.length > 0) { - console.error(` Extra: ${mismatch.extra.join(", ")}`); + assertCraftNpmTargets({ packages, targets }, errors); + assertVersionBumpConfig(root, errors); + assertActionBundleConfig(root, errors); + + if (errors.length > 0) { + throw new Error( + `${errors.join("\n")}\nRelease config check failed. Align release package lists with publishable package manifests.`, + ); } -} -if (hasMismatch) { - console.error( - "Release config check failed. Align release package lists with .craft.yml.", - ); - process.exit(1); + return { + packageCount: expectedPackages.length, + sourceCount: sources.length + 1, + }; } -console.log( - `Release config OK: ${expectedSource.packages.length} packages aligned across ${sources.length} sources.`, -); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + try { + const result = checkReleaseConfig(); + console.log( + `Release config OK: ${result.packageCount} packages aligned across ${result.sourceCount} sources.`, + ); + } catch (error) { + console.error(error.message); + process.exit(1); + } +} diff --git a/scripts/check-release-config.test.mjs b/scripts/check-release-config.test.mjs new file mode 100644 index 0000000..3764fcb --- /dev/null +++ b/scripts/check-release-config.test.mjs @@ -0,0 +1,212 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, expect, test } from "vitest"; +import { checkReleaseConfig } from "./check-release-config.mjs"; + +function writeFixtureFile(root, relativePath, contents) { + const absolutePath = path.join(root, relativePath); + mkdirSync(path.dirname(absolutePath), { recursive: true }); + writeFileSync(absolutePath, contents); +} + +function packageTarget(packageName, includeNames = undefined) { + const tarballBase = packageName.replace(/^@/, "").replaceAll("/", "-"); + const lines = [" - name: npm", ` id: "${packageName}"`]; + + if (packageName.startsWith("@")) { + lines.push(" access: public"); + } + + lines.push( + ` includeNames: ${includeNames ?? `/^${tarballBase}-\\d.*\\.tgz$/`}`, + ); + + return lines.join("\n"); +} + +function writeReleaseFixture({ + craftPackages, + packPackages = craftPackages, + publishablePackages = craftPackages, +}) { + const root = mkdtempSync(path.join(tmpdir(), "vitest-evals-release-")); + + for (const packageName of publishablePackages) { + const directory = packageName.replace(/^@vitest-evals\//, ""); + writeFixtureFile( + root, + `packages/${directory}/package.json`, + `${JSON.stringify( + { + name: packageName, + version: "1.2.3", + ...(packageName.startsWith("@") + ? { publishConfig: { access: "public" } } + : {}), + }, + null, + 2, + )}\n`, + ); + } + + writeFixtureFile( + root, + "packages/docs/package.json", + `${JSON.stringify( + { + name: "vitest-evals-docs", + private: true, + version: "0.0.1", + }, + null, + 2, + )}\n`, + ); + + writeFixtureFile( + root, + ".craft.yml", + [ + "preReleaseCommand: bash scripts/craft-pre-release.sh", + "targets:", + ...craftPackages.map((packageInfo) => + typeof packageInfo === "string" + ? packageTarget(packageInfo) + : packageTarget(packageInfo.name, packageInfo.includeNames), + ), + " - name: github", + " tagPrefix: v", + " includeNames: /^$/", + "", + ].join("\n"), + ); + + writeFixtureFile( + root, + ".github/workflows/merge-jobs.yml", + [ + "jobs:", + " build-publish:", + " steps:", + " - name: Pack NPM Tarballs", + " run: |", + " mkdir -p artifacts", + ...packPackages.map( + (packageName) => + ` pnpm --filter ${packageName} pack --pack-destination artifacts`, + ), + "", + ].join("\n"), + ); + + writeFixtureFile( + root, + "package.json", + `${JSON.stringify( + { + scripts: { + "build:action": + "pnpm --filter @vitest-evals/github-reporter run build:action", + }, + }, + null, + 2, + )}\n`, + ); + + writeFixtureFile( + root, + "scripts/craft-pre-release.sh", + [ + "#!/bin/bash", + 'NEW_VERSION="${2}"', + 'node scripts/bump-release-versions.mjs "${NEW_VERSION}"', + "", + ].join("\n"), + ); + + writeFixtureFile( + root, + "scripts/bump-release-versions.mjs", + 'import { collectPublishablePackages } from "./release-packages.mjs";\ncollectPublishablePackages();\n', + ); + + writeFixtureFile( + root, + "action.yml", + [ + "runs:", + ' using: "node24"', + ' main: "github-reporter/dist/action/index.js"', + "", + ].join("\n"), + ); + + writeFixtureFile( + root, + "packages/github-reporter/tsup.action.config.ts", + 'export default { outDir: "../../github-reporter/dist/action" };\n', + ); + + writeFixtureFile( + root, + ".github/workflows/update-action-tag.yml", + "steps:\n - run: git add -f github-reporter/dist/action/index.js\n", + ); + + writeFixtureFile( + root, + ".github/workflows/release.yml", + 'steps:\n - run: git cat-file -e "$CURRENT_TAG:github-reporter/dist/action/index.js"\n', + ); + + return root; +} + +describe("release config check", () => { + test("passes when publishable package manifests match release config", () => { + const root = writeReleaseFixture({ + craftPackages: [ + "vitest-evals", + "@vitest-evals/harness-ai-sdk", + "@vitest-evals/github-reporter", + ], + }); + + expect(checkReleaseConfig(root)).toMatchObject({ + packageCount: 3, + sourceCount: 3, + }); + }); + + test("fails when a publishable package is missing from Craft and packing", () => { + const root = writeReleaseFixture({ + craftPackages: ["vitest-evals"], + publishablePackages: ["vitest-evals", "@vitest-evals/harness-extra"], + }); + + expect(() => checkReleaseConfig(root)).toThrow( + /Missing: @vitest-evals\/harness-extra/, + ); + }); + + test("fails when Craft includeNames does not match the package tarball", () => { + const root = writeReleaseFixture({ + craftPackages: [ + "vitest-evals", + { + name: "@vitest-evals/harness-extra", + includeNames: "/^vitest-evals-\\d.*\\.tgz$/", + }, + ], + packPackages: ["vitest-evals", "@vitest-evals/harness-extra"], + publishablePackages: ["vitest-evals", "@vitest-evals/harness-extra"], + }); + + expect(() => checkReleaseConfig(root)).toThrow( + /includeNames does not match vitest-evals-harness-extra-1\.2\.3\.tgz/, + ); + }); +}); diff --git a/scripts/release-packages.mjs b/scripts/release-packages.mjs new file mode 100644 index 0000000..67514d2 --- /dev/null +++ b/scripts/release-packages.mjs @@ -0,0 +1,64 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; + +export function collectPublishablePackages(root = process.cwd()) { + const packagesDir = path.join(root, "packages"); + + if (!existsSync(packagesDir)) { + return []; + } + + return readdirSync(packagesDir) + .sort() + .map((directory) => { + const relativePath = path.posix.join( + "packages", + directory, + "package.json", + ); + const absolutePath = path.join(root, relativePath); + + if (!existsSync(absolutePath)) { + return null; + } + + const packageJson = JSON.parse(readFileSync(absolutePath, "utf8")); + + if (packageJson.private === true) { + return null; + } + + if ( + typeof packageJson.name !== "string" || + packageJson.name.length === 0 + ) { + throw new Error( + `${relativePath} is publishable but has no package name.`, + ); + } + + if ( + typeof packageJson.version !== "string" || + packageJson.version.length === 0 + ) { + throw new Error(`${relativePath} is publishable but has no version.`); + } + + return { + name: packageJson.name, + packageJson, + relativePath, + version: packageJson.version, + }; + }) + .filter(Boolean) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +export function packageTarballBaseName(packageName) { + return packageName.replace(/^@/, "").replaceAll("/", "-"); +} + +export function packageTarballName(packageInfo) { + return `${packageTarballBaseName(packageInfo.name)}-${packageInfo.version}.tgz`; +} From 715dbecb1d5b75c3167211edc1aed315772c8326 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Thu, 4 Jun 2026 09:22:29 +0200 Subject: [PATCH 2/2] ci(release): Include report packages in publishing Add the new core and report UI packages to Craft and post-merge tarball packing. Harden the release config checker against relative CLI invocation and regex-literal parsing issues reported by PR automation. Co-Authored-By: OpenAI Codex --- .craft.yml | 8 +++++ .github/workflows/merge-jobs.yml | 2 ++ scripts/check-release-config.mjs | 45 ++++++++++++++++++++++++--- scripts/check-release-config.test.mjs | 30 +++++++++++++++++- 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/.craft.yml b/.craft.yml index 706b313..37d459d 100644 --- a/.craft.yml +++ b/.craft.yml @@ -2,6 +2,14 @@ minVersion: 0.23.1 changelogPolicy: none preReleaseCommand: bash scripts/craft-pre-release.sh targets: + - name: npm + id: "@vitest-evals/core" + access: public + includeNames: /^vitest-evals-core-\d.*\.tgz$/ + - name: npm + id: "@vitest-evals/report-ui" + access: public + includeNames: /^vitest-evals-report-ui-\d.*\.tgz$/ - name: npm id: "vitest-evals" includeNames: /^vitest-evals-\d.*\.tgz$/ diff --git a/.github/workflows/merge-jobs.yml b/.github/workflows/merge-jobs.yml index 776b725..a552437 100644 --- a/.github/workflows/merge-jobs.yml +++ b/.github/workflows/merge-jobs.yml @@ -66,6 +66,8 @@ jobs: - name: Pack NPM Tarballs run: | mkdir -p artifacts + pnpm --filter @vitest-evals/core pack --pack-destination artifacts + pnpm --filter @vitest-evals/report-ui pack --pack-destination artifacts pnpm --filter vitest-evals pack --pack-destination artifacts pnpm --filter @vitest-evals/harness-ai-sdk pack --pack-destination artifacts pnpm --filter @vitest-evals/harness-openai-agents pack --pack-destination artifacts diff --git a/scripts/check-release-config.mjs b/scripts/check-release-config.mjs index 7925561..58e073f 100644 --- a/scripts/check-release-config.mjs +++ b/scripts/check-release-config.mjs @@ -56,14 +56,45 @@ function parseRegexLiteral(value, targetDescription) { throw new Error(`${targetDescription} must define includeNames.`); } - const match = value.match(/^\/((?:\\.|[^/])*)\/([a-z]*)$/i); - if (!match) { + if (!value.startsWith("/")) { throw new Error( `${targetDescription} includeNames must be a JavaScript regex literal.`, ); } - return new RegExp(match[1], match[2]); + let escaped = false; + for (let index = 1; index < value.length; index += 1) { + const character = value[index]; + + if (escaped) { + escaped = false; + continue; + } + + if (character === "\\") { + escaped = true; + continue; + } + + if (character !== "/") { + continue; + } + + const pattern = value.slice(1, index); + const flags = value.slice(index + 1); + + if (!/^[dgimsuvy]*$/.test(flags)) { + throw new Error( + `${targetDescription} includeNames has invalid regex flags.`, + ); + } + + return new RegExp(pattern, flags); + } + + throw new Error( + `${targetDescription} includeNames must be a JavaScript regex literal.`, + ); } function collectCraftPackages(root) { @@ -341,7 +372,13 @@ export function checkReleaseConfig(root = process.cwd()) { }; } -if (process.argv[1] === fileURLToPath(import.meta.url)) { +export function isCliEntrypoint(argv = process.argv) { + return Boolean( + argv[1] && path.resolve(argv[1]) === fileURLToPath(import.meta.url), + ); +} + +if (isCliEntrypoint()) { try { const result = checkReleaseConfig(); console.log( diff --git a/scripts/check-release-config.test.mjs b/scripts/check-release-config.test.mjs index 3764fcb..0948f20 100644 --- a/scripts/check-release-config.test.mjs +++ b/scripts/check-release-config.test.mjs @@ -2,7 +2,10 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; -import { checkReleaseConfig } from "./check-release-config.mjs"; +import { + checkReleaseConfig, + isCliEntrypoint, +} from "./check-release-config.mjs"; function writeFixtureFile(root, relativePath, contents) { const absolutePath = path.join(root, relativePath); @@ -209,4 +212,29 @@ describe("release config check", () => { /includeNames does not match vitest-evals-harness-extra-1\.2\.3\.tgz/, ); }); + + test("accepts escaped slashes in Craft includeNames regex literals", () => { + const root = writeReleaseFixture({ + craftPackages: [ + "vitest-evals", + { + name: "@vitest-evals/harness-extra", + includeNames: "/^vitest-evals-harness-extra-\\d.*\\.tgz(?:\\/)?$/", + }, + ], + packPackages: ["vitest-evals", "@vitest-evals/harness-extra"], + publishablePackages: ["vitest-evals", "@vitest-evals/harness-extra"], + }); + + expect(checkReleaseConfig(root)).toMatchObject({ + packageCount: 2, + sourceCount: 3, + }); + }); + + test("detects relative CLI invocation", () => { + expect( + isCliEntrypoint(["node", "./scripts/check-release-config.mjs"]), + ).toBe(true); + }); });