From 293845bc1508bfc5c7435c0d11e0090918bdf212 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 14 Oct 2026 14:48:30 -0700 Subject: [PATCH 1/2] fix: count prereleases on registries without a latest dist-tag (#163) --- .bumpy/prerelease-counter-no-latest-tag.md | 5 ++ packages/bumpy/src/commands/publish.ts | 5 +- packages/bumpy/src/core/prerelease.ts | 83 ++++++++++++++++-- packages/bumpy/src/core/snapshot.ts | 5 +- packages/bumpy/test/core/prerelease.test.ts | 95 ++++++++++++++++++++- 5 files changed, 180 insertions(+), 13 deletions(-) create mode 100644 .bumpy/prerelease-counter-no-latest-tag.md diff --git a/.bumpy/prerelease-counter-no-latest-tag.md b/.bumpy/prerelease-counter-no-latest-tag.md new file mode 100644 index 0000000..c52db7f --- /dev/null +++ b/.bumpy/prerelease-counter-no-latest-tag.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': patch +--- + +Fixed prerelease counters restarting at `.0` on registries that don't set a `latest` dist-tag (e.g. GCP Artifact Registry). `npm info versions` resolves through `latest` and prints nothing when it's missing, so bumpy now falls back to the channel's dist-tag and warns if the version list still can't be read. Snapshot idempotency now checks the exact version, and the OIDC new-package check no longer misreports packages without `latest` as missing. diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index abd4e6e..8af3b95 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -833,8 +833,9 @@ async function packageExistsOnNpm(name: string, registry?: string): Promise { +/** Published versions of a package, as listed by the registry */ +export interface PublishedVersions { + versions: string[]; + /** + * The package exists but its version list couldn't be read: `npm info ` resolves + * through a dist-tag (`latest` by default) and prints nothing when that tag is missing — + * which happens on registries that only set the tag you publish with (e.g. GCP Artifact + * Registry). `versions` is empty in this case, but not because nothing is published. + */ + unresolved: boolean; +} + +/** + * Run `npm info versions --json`. Returns the versions, `null` when npm printed + * nothing (the dist-tag it resolved through doesn't exist), or `'error'` when the command + * failed (package not found, registry unreachable, or a non-`latest` tag with no match). + */ +async function queryVersions(name: string, registry?: string, tag?: string): Promise { const args = ['npm', 'info', name, 'versions', '--json']; + if (tag) args.push('--tag', tag); if (registry) args.push('--registry', registry); + let raw: string; + try { + raw = await runArgsAsync(args); + } catch { + return 'error'; + } + if (!raw.trim()) return null; try { - const raw = await runArgsAsync(args); const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.filter((v) => typeof v === 'string'); if (typeof parsed === 'string') return [parsed]; // single-version packages return []; } catch { - // Package doesn't exist yet (first prerelease ever) or registry unreachable - return []; + return 'error'; + } +} + +/** + * Fetch all published versions of a package from the registry. + * + * Tries the default lookup (via `latest`) first, then falls back to `opts.tag` — the + * dist-tag bumpy publishes this release under — for packages that have no `latest`. + */ +export async function fetchPublishedVersions( + name: string, + opts: { registry?: string; tag?: string } = {}, +): Promise { + const viaLatest = await queryVersions(name, opts.registry); + // Package doesn't exist yet (first publish ever) or registry unreachable + if (viaLatest === 'error') return { versions: [], unresolved: false }; + if (viaLatest) return { versions: viaLatest, unresolved: false }; + + if (opts.tag && opts.tag !== 'latest') { + const viaTag = await queryVersions(name, opts.registry, opts.tag); + if (Array.isArray(viaTag)) return { versions: viaTag, unresolved: false }; + } + return { versions: [], unresolved: true }; +} + +/** Whether an exact version of a package is on the registry (no dist-tag resolution involved) */ +export async function isVersionOnRegistry(name: string, version: string, registry?: string): Promise { + const args = ['npm', 'info', `${name}@${version}`, 'version']; + if (registry) args.push('--registry', registry); + try { + return (await runArgsAsync(args)).trim() === version; + } catch { + return false; } } @@ -87,12 +144,17 @@ export async function getPublishedPrereleaseState( target: string, preid: string, rootDir: string, + distTag?: string, ): Promise { if (usesNpmRegistry(pkg)) { - const versions = await fetchPublishedVersions(pkg.name, pkg.bumpy?.registry); + const { versions, unresolved } = await fetchPublishedVersions(pkg.name, { + registry: pkg.bumpy?.registry, + tag: distTag, + }); return { counters: extractPrereleaseCounters(versions, target, preid), stablePublished: versions.includes(target), + unresolved, }; } // Non-npm packages (custom publish command / skipNpmPublish) — derive from git tags, @@ -150,7 +212,14 @@ export async function buildChannelReleasePlan( if (pkg.private && !pkg.bumpy?.publishCommand) return; const target = release.newVersion; // stable target from the bump files - const state = await getPublishedPrereleaseState(pkg, target, channel.preid, rootDir); + const state = await getPublishedPrereleaseState(pkg, target, channel.preid, rootDir, channel.tag); + + if (state.unresolved) { + warnings.push( + `${release.name} exists on the registry but has neither a "latest" nor a "${channel.tag}" dist-tag, ` + + `so its published versions couldn't be listed — the prerelease counter starts at 0 and may collide.`, + ); + } if (state.stablePublished) { warnings.push( diff --git a/packages/bumpy/src/core/snapshot.ts b/packages/bumpy/src/core/snapshot.ts index 1a808eb..888f08f 100644 --- a/packages/bumpy/src/core/snapshot.ts +++ b/packages/bumpy/src/core/snapshot.ts @@ -1,6 +1,6 @@ import { isPrerelease } from 'verkit'; import { tryRunArgs } from '../utils/shell.ts'; -import { fetchPublishedVersions, usesNpmRegistry } from './prerelease.ts'; +import { isVersionOnRegistry, usesNpmRegistry } from './prerelease.ts'; import type { BumpyConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; /** @@ -137,8 +137,7 @@ export async function buildSnapshotReleasePlan( // already published this exact snapshot — skip to stay idempotent (re-run on the // same commit). Only registry-backed packages can be checked this way. if (usesNpmRegistry(pkg)) { - const versions = await fetchPublishedVersions(pkg.name, pkg.bumpy?.registry); - if (versions.includes(version)) { + if (await isVersionOnRegistry(pkg.name, version, pkg.bumpy?.registry)) { alreadyPublished.push({ name: release.name, version }); return; } diff --git a/packages/bumpy/test/core/prerelease.test.ts b/packages/bumpy/test/core/prerelease.test.ts index 27cbba1..05055fb 100644 --- a/packages/bumpy/test/core/prerelease.test.ts +++ b/packages/bumpy/test/core/prerelease.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect } from 'bun:test'; +import { describe, test, expect, beforeEach, afterAll } from 'bun:test'; import { mkdir, writeFile, readFile } from 'node:fs/promises'; import { resolve } from 'node:path'; import { @@ -7,8 +7,12 @@ import { writeTransientVersionsInPlace, formatChannelVersionSummary, channelDisplayPlan, + fetchPublishedVersions, + isVersionOnRegistry, + buildChannelReleasePlan, } from '../../src/core/prerelease.ts'; import { makePkg, makeRelease, makeReleasePlan, createTempGitRepo, cleanupTempDir } from '../helpers.ts'; +import { resetMockState, uninstallShellMock, addMockRule, getCallsMatching } from '../helpers-shell-mock.ts'; describe('extractPrereleaseCounters', () => { test('extracts counters for matching target + preid only', () => { @@ -162,3 +166,92 @@ describe('channelDisplayPlan', () => { expect(display.releases.map((r) => r.name)).toEqual(['core', 'cli']); }); }); + +// Registries like GCP Artifact Registry only set the dist-tag you publish with, so a +// prerelease-only package has no `latest`. `npm info versions` resolves through +// `latest` and prints nothing (exit 0) in that case — see issue #163. +describe('registry version lookup', () => { + const NO_LATEST = { match: /^npm info pkg versions --json$/, response: '' }; + + beforeEach(() => resetMockState()); + afterAll(() => uninstallShellMock()); + + test('returns versions from the default lookup', async () => { + addMockRule({ match: 'npm info pkg versions --json', response: '["1.0.0","1.1.0-rc.0"]' }); + expect(await fetchPublishedVersions('pkg', { tag: 'next' })).toEqual({ + versions: ['1.0.0', '1.1.0-rc.0'], + unresolved: false, + }); + expect(getCallsMatching('--tag')).toHaveLength(0); + }); + + test('falls back to the dist-tag hint when there is no `latest`', async () => { + addMockRule(NO_LATEST); + addMockRule({ match: 'npm info pkg versions --json --tag alpha', response: '["0.1.0-alpha.0"]' }); + expect(await fetchPublishedVersions('pkg', { tag: 'alpha' })).toEqual({ + versions: ['0.1.0-alpha.0'], + unresolved: false, + }); + }); + + test('passes the registry through to both lookups', async () => { + addMockRule({ match: /^npm info pkg versions --json --registry https:\/\/r\.example$/, response: '' }); + addMockRule({ match: '--tag alpha --registry https://r.example', response: '"0.1.0-alpha.0"' }); + const result = await fetchPublishedVersions('pkg', { registry: 'https://r.example', tag: 'alpha' }); + expect(result.versions).toEqual(['0.1.0-alpha.0']); + }); + + test('flags unresolved when neither `latest` nor the hint resolve', async () => { + addMockRule(NO_LATEST); + addMockRule({ match: '--tag alpha', error: 'No match found for version alpha' }); + expect(await fetchPublishedVersions('pkg', { tag: 'alpha' })).toEqual({ versions: [], unresolved: true }); + resetMockState(); + addMockRule(NO_LATEST); + expect(await fetchPublishedVersions('pkg')).toEqual({ versions: [], unresolved: true }); + }); + + test('a missing package is not unresolved', async () => { + addMockRule({ match: 'npm info pkg versions', error: 'E404 Not Found' }); + expect(await fetchPublishedVersions('pkg', { tag: 'alpha' })).toEqual({ versions: [], unresolved: false }); + }); + + test('isVersionOnRegistry checks the exact version', async () => { + addMockRule({ match: 'npm info pkg@1.0.0-pr-1-abc version', response: '1.0.0-pr-1-abc' }); + addMockRule({ match: 'npm info pkg@1.0.0-pr-2-abc version', error: 'E404' }); + expect(await isVersionOnRegistry('pkg', '1.0.0-pr-1-abc')).toBe(true); + expect(await isVersionOnRegistry('pkg', '1.0.0-pr-2-abc')).toBe(false); + }); + + describe('buildChannelReleasePlan', () => { + const channel = { + name: 'alpha', + branch: 'alpha', + preid: 'alpha', + tag: 'alpha', + versionPr: { title: 'Versioned prerelease (alpha)', branch: 'bumpy/release-alpha', automerge: false }, + }; + const plan = makeReleasePlan([makeRelease('pkg', '0.1.0', { oldVersion: '0.0.0' })]); + const packages = new Map([['pkg', makePkg('pkg', '0.0.0')]]); + + test('counts prereleases on a registry without `latest`', async () => { + addMockRule(NO_LATEST); + addMockRule({ match: '--tag alpha', response: '["0.1.0-alpha.0","0.1.0-alpha.1"]' }); + const { plan: out, warnings } = await buildChannelReleasePlan(plan, channel, packages, process.cwd(), { + forDisplay: true, + }); + expect(out.releases[0]!.newVersion).toBe('0.1.0-alpha.2'); + expect(warnings).toEqual([]); + }); + + test('warns when the version list cannot be read', async () => { + addMockRule(NO_LATEST); + addMockRule({ match: '--tag alpha', error: 'No match found for version alpha' }); + const { plan: out, warnings } = await buildChannelReleasePlan(plan, channel, packages, process.cwd(), { + forDisplay: true, + }); + expect(out.releases[0]!.newVersion).toBe('0.1.0-alpha.0'); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('neither a "latest" nor a "alpha" dist-tag'); + }); + }); +}); From 330ba1e1e786737c9400ceeee47f5e007eb53fbb Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 14 Oct 2026 15:13:51 -0700 Subject: [PATCH 2/2] fix: distinguish registry lookup failures from missing packages --- packages/bumpy/src/core/prerelease.ts | 54 ++++++++++++--------- packages/bumpy/test/core/prerelease.test.ts | 36 ++++++++++++-- 2 files changed, 63 insertions(+), 27 deletions(-) diff --git a/packages/bumpy/src/core/prerelease.ts b/packages/bumpy/src/core/prerelease.ts index d7d7a65..a270dd4 100644 --- a/packages/bumpy/src/core/prerelease.ts +++ b/packages/bumpy/src/core/prerelease.ts @@ -20,8 +20,8 @@ export interface PublishedPrereleaseState { counters: number[]; /** Whether the stable target version itself is already published */ stablePublished: boolean; - /** The registry's version list couldn't be read (see `PublishedVersions.unresolved`) */ - unresolved?: boolean; + /** Why the registry's version list couldn't be read, if it couldn't (see `PublishedVersions.unresolved`) */ + unresolved?: PublishedVersions['unresolved']; } /** @@ -53,37 +53,41 @@ export function nextPrereleaseVersion(target: string, preid: string, existingCou export interface PublishedVersions { versions: string[]; /** - * The package exists but its version list couldn't be read: `npm info ` resolves - * through a dist-tag (`latest` by default) and prints nothing when that tag is missing — - * which happens on registries that only set the tag you publish with (e.g. GCP Artifact - * Registry). `versions` is empty in this case, but not because nothing is published. + * Why the version list couldn't be read (`versions` is empty, but not because nothing is published): + * - `no-dist-tag` — the package exists, but `npm info ` resolves through a dist-tag + * (`latest` by default) and prints nothing when it's missing, which happens on registries + * that only set the tag you publish with (e.g. GCP Artifact Registry) + * - `lookup-failed` — the registry query failed for a reason other than "not found" + * (auth, network, 5xx, malformed response) */ - unresolved: boolean; + unresolved: false | 'no-dist-tag' | 'lookup-failed'; } +type VersionsQueryResult = string[] | 'empty' | 'not-found' | 'failed'; + /** - * Run `npm info versions --json`. Returns the versions, `null` when npm printed - * nothing (the dist-tag it resolved through doesn't exist), or `'error'` when the command - * failed (package not found, registry unreachable, or a non-`latest` tag with no match). + * Run `npm info versions --json`. `empty` means npm printed nothing (the dist-tag it + * resolved through doesn't exist); `not-found` is an E404 (no such package, or a non-`latest` + * tag with no match); `failed` is anything else. */ -async function queryVersions(name: string, registry?: string, tag?: string): Promise { +async function queryVersions(name: string, registry?: string, tag?: string): Promise { const args = ['npm', 'info', name, 'versions', '--json']; if (tag) args.push('--tag', tag); if (registry) args.push('--registry', registry); let raw: string; try { raw = await runArgsAsync(args); - } catch { - return 'error'; + } catch (err) { + return /\bE404\b/.test(err instanceof Error ? err.message : String(err)) ? 'not-found' : 'failed'; } - if (!raw.trim()) return null; + if (!raw.trim()) return 'empty'; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.filter((v) => typeof v === 'string'); if (typeof parsed === 'string') return [parsed]; // single-version packages - return []; + return 'failed'; } catch { - return 'error'; + return 'failed'; } } @@ -98,15 +102,17 @@ export async function fetchPublishedVersions( opts: { registry?: string; tag?: string } = {}, ): Promise { const viaLatest = await queryVersions(name, opts.registry); - // Package doesn't exist yet (first publish ever) or registry unreachable - if (viaLatest === 'error') return { versions: [], unresolved: false }; - if (viaLatest) return { versions: viaLatest, unresolved: false }; + if (Array.isArray(viaLatest)) return { versions: viaLatest, unresolved: false }; + // Package doesn't exist yet (first publish ever) + if (viaLatest === 'not-found') return { versions: [], unresolved: false }; + if (viaLatest === 'failed') return { versions: [], unresolved: 'lookup-failed' }; if (opts.tag && opts.tag !== 'latest') { const viaTag = await queryVersions(name, opts.registry, opts.tag); if (Array.isArray(viaTag)) return { versions: viaTag, unresolved: false }; + if (viaTag === 'failed') return { versions: [], unresolved: 'lookup-failed' }; } - return { versions: [], unresolved: true }; + return { versions: [], unresolved: 'no-dist-tag' }; } /** Whether an exact version of a package is on the registry (no dist-tag resolution involved) */ @@ -215,9 +221,13 @@ export async function buildChannelReleasePlan( const state = await getPublishedPrereleaseState(pkg, target, channel.preid, rootDir, channel.tag); if (state.unresolved) { + const reason = + state.unresolved === 'no-dist-tag' + ? `exists on the registry but has neither a "latest" nor a "${channel.tag}" dist-tag` + : 'could not be looked up on the registry'; warnings.push( - `${release.name} exists on the registry but has neither a "latest" nor a "${channel.tag}" dist-tag, ` + - `so its published versions couldn't be listed — the prerelease counter starts at 0 and may collide.`, + `${release.name} ${reason}, so its published versions couldn't be listed — ` + + `the prerelease counter starts at 0 and may collide.`, ); } diff --git a/packages/bumpy/test/core/prerelease.test.ts b/packages/bumpy/test/core/prerelease.test.ts index 05055fb..323cf62 100644 --- a/packages/bumpy/test/core/prerelease.test.ts +++ b/packages/bumpy/test/core/prerelease.test.ts @@ -203,18 +203,37 @@ describe('registry version lookup', () => { test('flags unresolved when neither `latest` nor the hint resolve', async () => { addMockRule(NO_LATEST); - addMockRule({ match: '--tag alpha', error: 'No match found for version alpha' }); - expect(await fetchPublishedVersions('pkg', { tag: 'alpha' })).toEqual({ versions: [], unresolved: true }); + addMockRule({ match: '--tag alpha', error: 'npm error code E404\nNo match found for version alpha' }); + expect(await fetchPublishedVersions('pkg', { tag: 'alpha' })).toEqual({ versions: [], unresolved: 'no-dist-tag' }); resetMockState(); addMockRule(NO_LATEST); - expect(await fetchPublishedVersions('pkg')).toEqual({ versions: [], unresolved: true }); + expect(await fetchPublishedVersions('pkg')).toEqual({ versions: [], unresolved: 'no-dist-tag' }); }); test('a missing package is not unresolved', async () => { - addMockRule({ match: 'npm info pkg versions', error: 'E404 Not Found' }); + addMockRule({ match: 'npm info pkg versions', error: 'npm error code E404\nnpm error 404 Not Found' }); expect(await fetchPublishedVersions('pkg', { tag: 'alpha' })).toEqual({ versions: [], unresolved: false }); }); + test('flags lookup-failed for non-404 failures', async () => { + addMockRule({ match: 'npm info pkg versions', error: 'npm error code E401\nnpm error Unable to authenticate' }); + expect(await fetchPublishedVersions('pkg', { tag: 'alpha' })).toEqual({ + versions: [], + unresolved: 'lookup-failed', + }); + resetMockState(); + addMockRule({ match: 'npm info pkg versions', response: 'bad gateway' }); + expect(await fetchPublishedVersions('pkg')).toEqual({ versions: [], unresolved: 'lookup-failed' }); + // the dist-tag fallback failing for a non-404 reason is a lookup failure too + resetMockState(); + addMockRule(NO_LATEST); + addMockRule({ match: '--tag alpha', error: 'npm error code ETIMEDOUT' }); + expect(await fetchPublishedVersions('pkg', { tag: 'alpha' })).toEqual({ + versions: [], + unresolved: 'lookup-failed', + }); + }); + test('isVersionOnRegistry checks the exact version', async () => { addMockRule({ match: 'npm info pkg@1.0.0-pr-1-abc version', response: '1.0.0-pr-1-abc' }); addMockRule({ match: 'npm info pkg@1.0.0-pr-2-abc version', error: 'E404' }); @@ -245,7 +264,7 @@ describe('registry version lookup', () => { test('warns when the version list cannot be read', async () => { addMockRule(NO_LATEST); - addMockRule({ match: '--tag alpha', error: 'No match found for version alpha' }); + addMockRule({ match: '--tag alpha', error: 'npm error code E404\nNo match found for version alpha' }); const { plan: out, warnings } = await buildChannelReleasePlan(plan, channel, packages, process.cwd(), { forDisplay: true, }); @@ -253,5 +272,12 @@ describe('registry version lookup', () => { expect(warnings).toHaveLength(1); expect(warnings[0]).toContain('neither a "latest" nor a "alpha" dist-tag'); }); + + test('warns when the registry lookup fails', async () => { + addMockRule({ match: 'npm info pkg versions', error: 'npm error code E500' }); + const { warnings } = await buildChannelReleasePlan(plan, channel, packages, process.cwd(), { forDisplay: true }); + expect(warnings).toHaveLength(1); + expect(warnings[0]).toContain('could not be looked up on the registry'); + }); }); });