perf(release): batch npm registry version queries (#9202)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The release subsystem publishes the public workspace packages and also powers release-related CI validation. > - The release flow currently asks npm for package versions one package at a time in multiple places. > - That serial registry latency slows the PR Canary Dry Run path and real release invocations even though the checks are independent. > - This pull request batches npm registry version lookups with bounded concurrency and reuses the result for version calculation. > - The benefit is shorter non-build release-script time while preserving the fresh target-version existence check before publishing. ## Linked Issues or Issue Description - No public GitHub issue exists for this release-script performance cleanup. ### Problem or motivation Release validation spends avoidable time on repeated serial `npm view` calls across the public package set. The slow path affects PR release validation and real release invocations because version discovery waits on independent registry reads one at a time. ### Proposed solution Fetch package version maps concurrently with bounded parallelism, reuse that map for stable/canary version calculation, and keep a fresh parallel absence check for the target publish version. ### Alternatives considered Keeping the existing serial shell loop is simpler, but it preserves the CI latency cost. Caching the final target-version existence check was rejected because release publish safety should still query npm freshly before publishing. ### Roadmap alignment This is a small release-tooling performance improvement. It does not duplicate any planned core product work found in `ROADMAP.md`. ## What Changed - Added `scripts/release-registry-versions.mjs` to fetch npm package version maps and assert target-version absence with bounded parallelism. - Updated `scripts/release.sh` to prefetch package versions once and to batch the final target-version absence check. - Updated `next_stable_version` and `next_canary_version` to use the prefetched version map when present, with the existing per-package npm fallback preserved. - Added release-registry helper coverage and included it in `pnpm run test:release-registry`. - Hardened the release publish helper tests so their fake `pnpm`/`npm` fixture PATH is preserved under non-login shell execution. ## Verification - `node --test scripts/release-registry-versions.test.mjs` - `pnpm run test:release-registry` - `bash -n scripts/release.sh scripts/release-lib.sh` - `git diff --check` - Safety scan before push: searched changed files for common key/token/password patterns and PII markers; only benign script-name text matched (`secrets:migrate-inline-env`). - Remote PR checks on the latest head passed, including `Typecheck + Release Registry`, `Canary Dry Run`, build, tests, e2e, policy, security scans, and commitperclip review. - Greptile reviewed the latest head with Confidence Score 5/5 and no blocking issues. ## Risks - Low risk. The release version helpers keep their original npm fallback when no prefetched version map is supplied. - The existence check remains fresh and uncached before publish, but now reports all matching package/version pairs from a parallel check. - If npm has transient failures during the prefetch step, missing or failed packages still map to an empty version list, matching the old helper behavior. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agent using GPT-5, with shell/tool execution in the local repository. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude <noreply@paperclip.ing>
This commit is contained in:
parent
562567fcd6
commit
e68ee09809
|
|
@ -48,7 +48,7 @@
|
|||
"smoke:openclaw-sse-standalone": "./scripts/smoke/openclaw-sse-standalone.sh",
|
||||
"smoke:pipelines-tutorial": "./scripts/smoke/pipelines-tutorial-smoke.sh",
|
||||
"smoke:terminal-bench-loop-skill": "node scripts/smoke/terminal-bench-loop-skill-smoke.mjs",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/link-plugin-dev-sdk.test.js",
|
||||
"test:release-registry": "node --test scripts/verify-release-registry-state.test.mjs scripts/release-package-map.test.mjs scripts/check-release-package-bootstrap.test.mjs scripts/check-no-git-push.test.mjs scripts/release-lib.test.mjs scripts/release-registry-versions.test.mjs scripts/link-plugin-dev-sdk.test.js",
|
||||
"storybook-visual:baseline": "node scripts/storybook-visual-baseline.mjs",
|
||||
"test:storybook-visual": "node scripts/storybook-visual-baseline.mjs download && node scripts/storybook-visual-baseline.mjs verify && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts",
|
||||
"test:storybook-visual:update": "node scripts/storybook-visual-baseline.mjs download && pnpm build-storybook && npx playwright test --config tests/storybook-visual/playwright.config.ts --update-snapshots && node scripts/storybook-visual-baseline.mjs pack",
|
||||
|
|
|
|||
|
|
@ -142,6 +142,7 @@ next_stable_version() {
|
|||
const input = process.argv[2];
|
||||
const packageNames = process.argv.slice(3);
|
||||
const { execSync } = require("node:child_process");
|
||||
const { readFileSync } = require("node:fs");
|
||||
|
||||
const date = input ? new Date(`${input}T00:00:00Z`) : new Date();
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
|
|
@ -149,6 +150,17 @@ if (Number.isNaN(date.getTime())) {
|
|||
process.exit(1);
|
||||
}
|
||||
|
||||
// Optional pre-fetched version data (see release-registry-versions.mjs).
|
||||
// Avoids one serial `npm view` round-trip per package.
|
||||
let versionsCache = null;
|
||||
if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) {
|
||||
try {
|
||||
versionsCache = JSON.parse(readFileSync(process.env.RELEASE_PACKAGE_VERSIONS_FILE, "utf8"));
|
||||
} catch {
|
||||
versionsCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
const stableSlot = `${date.getUTCFullYear()}.${date.getUTCMonth() + 1}${String(date.getUTCDate()).padStart(2, "0")}`;
|
||||
const pattern = new RegExp(`^${stableSlot.replace(/\./g, '\\.')}\.(\\d+)$`);
|
||||
let max = -1;
|
||||
|
|
@ -156,18 +168,22 @@ let max = -1;
|
|||
for (const packageName of packageNames) {
|
||||
let versions = [];
|
||||
|
||||
try {
|
||||
const raw = execSync(`npm view ${JSON.stringify(packageName)} versions --json`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (versionsCache && Array.isArray(versionsCache[packageName])) {
|
||||
versions = versionsCache[packageName];
|
||||
} else {
|
||||
try {
|
||||
const raw = execSync(`npm view ${JSON.stringify(packageName)} versions --json`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
|
||||
for (const version of versions) {
|
||||
|
|
@ -189,6 +205,18 @@ next_canary_version() {
|
|||
const stable = process.argv[2];
|
||||
const packageNames = process.argv.slice(3);
|
||||
const { execSync } = require("node:child_process");
|
||||
const { readFileSync } = require("node:fs");
|
||||
|
||||
// Optional pre-fetched version data (see release-registry-versions.mjs).
|
||||
// Avoids one serial `npm view` round-trip per package.
|
||||
let versionsCache = null;
|
||||
if (process.env.RELEASE_PACKAGE_VERSIONS_FILE) {
|
||||
try {
|
||||
versionsCache = JSON.parse(readFileSync(process.env.RELEASE_PACKAGE_VERSIONS_FILE, "utf8"));
|
||||
} catch {
|
||||
versionsCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
const pattern = new RegExp(`^${stable.replace(/\./g, '\\.')}-canary\\.(\\d+)$`);
|
||||
let max = -1;
|
||||
|
|
@ -196,20 +224,24 @@ let max = -1;
|
|||
for (const packageName of packageNames) {
|
||||
let versions = [];
|
||||
|
||||
try {
|
||||
const raw = execSync(`npm view ${JSON.stringify(packageName)} versions --json`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
if (versionsCache && Array.isArray(versionsCache[packageName])) {
|
||||
versions = versionsCache[packageName];
|
||||
} else {
|
||||
try {
|
||||
const raw = execSync(`npm view ${JSON.stringify(packageName)} versions --json`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}).trim();
|
||||
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
versions = Array.isArray(parsed) ? parsed : [parsed];
|
||||
}
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
} catch {
|
||||
versions = [];
|
||||
}
|
||||
|
||||
|
||||
for (const version of versions) {
|
||||
const match = version.match(pattern);
|
||||
if (!match) continue;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ function runPublishHelper({ pnpmMode, npmVersionExists = false, distTag = "canar
|
|||
const callLog = join(fixtureDir, "calls.log");
|
||||
mkdirSync(binDir);
|
||||
mkdirSync(stateDir);
|
||||
writeFileSync(callLog, "");
|
||||
|
||||
writeExecutable(
|
||||
join(binDir, "pnpm"),
|
||||
|
|
@ -84,7 +85,7 @@ publish_package_to_npm ${distTag} @paperclipai/example 1.2.3
|
|||
let status = 0;
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync("bash", ["-lc", script], {
|
||||
output = execFileSync("bash", ["-c", script], {
|
||||
cwd: fixtureDir,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env node
|
||||
// Batched npm registry version queries for the release tooling.
|
||||
//
|
||||
// The release flow needs published-version data for every public workspace
|
||||
// package. Querying them one `npm view` at a time is serial network latency
|
||||
// that dominates the non-build time of `release.sh` (and the PR workflow's
|
||||
// Canary Dry Run job). This helper runs the same `npm view` queries with
|
||||
// bounded concurrency instead.
|
||||
//
|
||||
// Usage:
|
||||
// node scripts/release-registry-versions.mjs fetch <pkg...>
|
||||
// Prints a JSON object mapping each package name to its published
|
||||
// versions array. Packages that are missing from the registry (or fail
|
||||
// to resolve) map to [].
|
||||
//
|
||||
// node scripts/release-registry-versions.mjs assert-absent <version> <pkg...>
|
||||
// Freshly checks that <version> is not published for any <pkg>. Exits 0
|
||||
// when absent everywhere; prints the offending package@version pairs to
|
||||
// stderr and exits 1 otherwise.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
|
||||
const CONCURRENCY = Number(process.env.RELEASE_REGISTRY_CONCURRENCY || 10);
|
||||
if (!Number.isInteger(CONCURRENCY) || CONCURRENCY < 1) {
|
||||
console.error("RELEASE_REGISTRY_CONCURRENCY must be a positive integer.");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
function npmView(args) {
|
||||
return new Promise((resolve) => {
|
||||
execFile("npm", ["view", ...args], { encoding: "utf8" }, (error, stdout) => {
|
||||
if (error) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, limit, fn) {
|
||||
const results = new Array(items.length);
|
||||
let next = 0;
|
||||
|
||||
async function worker() {
|
||||
while (next < items.length) {
|
||||
const index = next;
|
||||
next += 1;
|
||||
results[index] = await fn(items[index]);
|
||||
}
|
||||
}
|
||||
|
||||
const workers = [];
|
||||
for (let i = 0; i < Math.min(limit, items.length); i += 1) {
|
||||
workers.push(worker());
|
||||
}
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function fetchVersions(packageNames) {
|
||||
const versionLists = await mapWithConcurrency(packageNames, CONCURRENCY, async (packageName) => {
|
||||
const raw = await npmView([packageName, "versions", "--json"]);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [parsed];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const map = {};
|
||||
packageNames.forEach((packageName, index) => {
|
||||
map[packageName] = versionLists[index];
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
async function assertAbsent(version, packageNames) {
|
||||
const resolved = await mapWithConcurrency(packageNames, CONCURRENCY, async (packageName) => {
|
||||
const raw = await npmView([`${packageName}@${version}`, "version"]);
|
||||
return raw === version ? packageName : null;
|
||||
});
|
||||
|
||||
return resolved.filter((packageName) => packageName !== null);
|
||||
}
|
||||
|
||||
const [mode, ...rest] = process.argv.slice(2);
|
||||
|
||||
if (mode === "fetch") {
|
||||
if (rest.length === 0) {
|
||||
console.error("usage: release-registry-versions.mjs fetch <pkg...>");
|
||||
process.exit(2);
|
||||
}
|
||||
const map = await fetchVersions(rest);
|
||||
process.stdout.write(`${JSON.stringify(map)}\n`);
|
||||
} else if (mode === "assert-absent") {
|
||||
const [version, ...packageNames] = rest;
|
||||
if (!version || packageNames.length === 0) {
|
||||
console.error("usage: release-registry-versions.mjs assert-absent <version> <pkg...>");
|
||||
process.exit(2);
|
||||
}
|
||||
const existing = await assertAbsent(version, packageNames);
|
||||
if (existing.length > 0) {
|
||||
for (const packageName of existing) {
|
||||
console.error(`npm version ${packageName}@${version} already exists.`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
} else {
|
||||
console.error("usage: release-registry-versions.mjs <fetch|assert-absent> ...");
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const repoRoot = new URL("..", import.meta.url).pathname.replace(/\/$/, "");
|
||||
const scriptPath = join(repoRoot, "scripts", "release-registry-versions.mjs");
|
||||
|
||||
function writeExecutable(path, body) {
|
||||
writeFileSync(path, body, { mode: 0o755 });
|
||||
}
|
||||
|
||||
function makeFixture() {
|
||||
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-release-registry-"));
|
||||
const binDir = join(fixtureDir, "bin");
|
||||
const callLog = join(fixtureDir, "calls.log");
|
||||
mkdirSync(binDir);
|
||||
writeFileSync(callLog, "");
|
||||
|
||||
writeExecutable(
|
||||
join(binDir, "npm"),
|
||||
`#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf 'npm %s\\n' "$*" >> "$FAKE_CALL_LOG"
|
||||
target="$2"
|
||||
case "$target" in
|
||||
"@paperclipai/present@"*)
|
||||
printf '%s\\n' "\${target##*@}"
|
||||
;;
|
||||
"@paperclipai/absent@"*)
|
||||
exit 1
|
||||
;;
|
||||
"@paperclipai/present")
|
||||
echo '["1.0.0","2026.707.0","2026.707.1","2026.707.1-canary.4"]'
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
`,
|
||||
);
|
||||
|
||||
return { fixtureDir, binDir, callLog };
|
||||
}
|
||||
|
||||
function runScript(args, { binDir, callLog }, extraEnv = {}) {
|
||||
let status = 0;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
try {
|
||||
stdout = execFileSync("node", [scriptPath, ...args], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
FAKE_CALL_LOG: callLog,
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
status = error.status ?? 1;
|
||||
stdout = error.stdout ?? "";
|
||||
stderr = error.stderr ?? "";
|
||||
}
|
||||
return { status, stdout, stderr, calls: readFileSync(callLog, "utf8") };
|
||||
}
|
||||
|
||||
function runReleaseLibHelper(fnCall, { binDir, callLog }, extraEnv = {}) {
|
||||
const script = `
|
||||
set -euo pipefail
|
||||
source "${repoRoot}/scripts/release-lib.sh"
|
||||
${fnCall}
|
||||
`;
|
||||
let status = 0;
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync("bash", ["-c", script], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${binDir}:${process.env.PATH}`,
|
||||
FAKE_CALL_LOG: callLog,
|
||||
REPO_ROOT: repoRoot,
|
||||
...extraEnv,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
} catch (error) {
|
||||
status = error.status ?? 1;
|
||||
output = `${error.stdout ?? ""}${error.stderr ?? ""}`;
|
||||
}
|
||||
return { status, output, calls: readFileSync(callLog, "utf8") };
|
||||
}
|
||||
|
||||
test("fetch prints a JSON version map and treats missing packages as empty", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runScript(["fetch", "@paperclipai/present", "@paperclipai/missing"], fixture);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
const map = JSON.parse(result.stdout);
|
||||
assert.deepEqual(map["@paperclipai/present"], [
|
||||
"1.0.0",
|
||||
"2026.707.0",
|
||||
"2026.707.1",
|
||||
"2026.707.1-canary.4",
|
||||
]);
|
||||
assert.deepEqual(map["@paperclipai/missing"], []);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/present versions --json$/m);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/missing versions --json$/m);
|
||||
});
|
||||
|
||||
test("assert-absent succeeds when no package has the version", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runScript(
|
||||
["assert-absent", "2026.707.2", "@paperclipai/absent", "@paperclipai/absent"],
|
||||
fixture,
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.calls, /^npm view @paperclipai\/absent@2026\.707\.2 version$/m);
|
||||
});
|
||||
|
||||
test("assert-absent fails and names packages that already have the version", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runScript(
|
||||
["assert-absent", "2026.707.2", "@paperclipai/present", "@paperclipai/absent"],
|
||||
fixture,
|
||||
);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /npm version @paperclipai\/present@2026\.707\.2 already exists\./);
|
||||
assert.doesNotMatch(result.stderr, /@paperclipai\/absent@/);
|
||||
});
|
||||
|
||||
test("invalid concurrency fails instead of skipping registry checks", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runScript(["assert-absent", "2026.707.2", "@paperclipai/present"], fixture, {
|
||||
RELEASE_REGISTRY_CONCURRENCY: "0",
|
||||
});
|
||||
|
||||
assert.equal(result.status, 2);
|
||||
assert.match(result.stderr, /RELEASE_REGISTRY_CONCURRENCY must be a positive integer\./);
|
||||
assert.equal(result.calls, "");
|
||||
});
|
||||
|
||||
test("next_stable_version reads RELEASE_PACKAGE_VERSIONS_FILE without calling npm", () => {
|
||||
const fixture = makeFixture();
|
||||
const versionsFile = join(fixture.fixtureDir, "versions.json");
|
||||
writeFileSync(
|
||||
versionsFile,
|
||||
JSON.stringify({
|
||||
"@paperclipai/a": ["2026.707.0", "2026.707.1", "2026.707.1-canary.4"],
|
||||
"@paperclipai/b": [],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = runReleaseLibHelper(
|
||||
'next_stable_version 2026-07-07 "@paperclipai/a" "@paperclipai/b"',
|
||||
fixture,
|
||||
{ RELEASE_PACKAGE_VERSIONS_FILE: versionsFile },
|
||||
);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.output, "2026.707.2");
|
||||
assert.doesNotMatch(result.calls, /npm view/);
|
||||
});
|
||||
|
||||
test("next_canary_version reads RELEASE_PACKAGE_VERSIONS_FILE without calling npm", () => {
|
||||
const fixture = makeFixture();
|
||||
const versionsFile = join(fixture.fixtureDir, "versions.json");
|
||||
writeFileSync(
|
||||
versionsFile,
|
||||
JSON.stringify({
|
||||
"@paperclipai/a": ["2026.707.0", "2026.707.1", "2026.707.1-canary.4"],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = runReleaseLibHelper('next_canary_version 2026.707.1 "@paperclipai/a"', fixture, {
|
||||
RELEASE_PACKAGE_VERSIONS_FILE: versionsFile,
|
||||
});
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.output, "2026.707.1-canary.5");
|
||||
assert.doesNotMatch(result.calls, /npm view/);
|
||||
});
|
||||
|
||||
test("next_stable_version falls back to npm view without a versions file", () => {
|
||||
const fixture = makeFixture();
|
||||
const result = runReleaseLibHelper('next_stable_version 2026-07-07 "@paperclipai/present"', fixture);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.output, "2026.707.2");
|
||||
assert.match(result.calls, /^npm view @paperclipai\/present versions --json$/m);
|
||||
});
|
||||
|
|
@ -133,6 +133,13 @@ done < <(printf '%s\n' "$PUBLIC_PACKAGE_INFO" | cut -f2)
|
|||
|
||||
[ -n "$PUBLIC_PACKAGE_INFO" ] || release_fail "no public packages were found in the workspace."
|
||||
|
||||
# Pre-fetch published versions for every public package in parallel so the
|
||||
# version helpers below do not each issue one serial `npm view` call per
|
||||
# package (see scripts/release-registry-versions.mjs).
|
||||
RELEASE_PACKAGE_VERSIONS_FILE="$(mktemp)"
|
||||
export RELEASE_PACKAGE_VERSIONS_FILE
|
||||
node "$REPO_ROOT/scripts/release-registry-versions.mjs" fetch "${PUBLIC_PACKAGE_NAMES[@]}" > "$RELEASE_PACKAGE_VERSIONS_FILE"
|
||||
|
||||
TARGET_STABLE_VERSION="$(next_stable_version "$RELEASE_DATE" "${PUBLIC_PACKAGE_NAMES[@]}")"
|
||||
TARGET_PUBLISH_VERSION="$TARGET_STABLE_VERSION"
|
||||
DIST_TAG="latest"
|
||||
|
|
@ -146,6 +153,9 @@ else
|
|||
tag_name="$(stable_tag_name "$TARGET_STABLE_VERSION")"
|
||||
fi
|
||||
|
||||
rm -f "$RELEASE_PACKAGE_VERSIONS_FILE"
|
||||
unset RELEASE_PACKAGE_VERSIONS_FILE
|
||||
|
||||
if [ "$print_version_only" = true ]; then
|
||||
printf '%s\n' "$TARGET_PUBLISH_VERSION"
|
||||
exit 0
|
||||
|
|
@ -168,12 +178,10 @@ if git_local_tag_exists "$tag_name" || git_remote_tag_exists "$tag_name" "$PUBLI
|
|||
release_fail "git tag $tag_name already exists locally or on $PUBLISH_REMOTE."
|
||||
fi
|
||||
|
||||
while IFS= read -r package_name; do
|
||||
[ -z "$package_name" ] && continue
|
||||
if npm_package_version_exists "$package_name" "$TARGET_PUBLISH_VERSION"; then
|
||||
release_fail "npm version ${package_name}@${TARGET_PUBLISH_VERSION} already exists."
|
||||
fi
|
||||
done <<< "$(printf '%s\n' "${PUBLIC_PACKAGE_NAMES[@]}")"
|
||||
# Fresh (non-cached) existence check, batched in parallel. Prints the
|
||||
# offending package@version pairs itself before failing.
|
||||
node "$REPO_ROOT/scripts/release-registry-versions.mjs" assert-absent "$TARGET_PUBLISH_VERSION" "${PUBLIC_PACKAGE_NAMES[@]}" \
|
||||
|| release_fail "npm version ${TARGET_PUBLISH_VERSION} already exists for one or more packages."
|
||||
|
||||
release_info ""
|
||||
release_info "==> Release plan"
|
||||
|
|
|
|||
Loading…
Reference in New Issue