Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .bumpy/prerelease-counter-no-latest-tag.md
Original file line number Diff line number Diff line change
@@ -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 <name> 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.
5 changes: 3 additions & 2 deletions packages/bumpy/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -833,8 +833,9 @@ async function packageExistsOnNpm(name: string, registry?: string): Promise<bool
const args = ['npm', 'info', name, 'name'];
if (registry) args.push('--registry', registry);
try {
const result = await runArgsAsync(args);
return result.trim() === name;
// Exit 0 means the package exists — output can be empty when it has no `latest` dist-tag
await runArgsAsync(args);
return true;
} catch {
return false;
}
Expand Down
95 changes: 87 additions & 8 deletions packages/bumpy/src/core/prerelease.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ export interface PublishedPrereleaseState {
counters: number[];
/** Whether the stable target version itself is already published */
stablePublished: boolean;
/** Why the registry's version list couldn't be read, if it couldn't (see `PublishedVersions.unresolved`) */
unresolved?: PublishedVersions['unresolved'];
}

/**
Expand Down Expand Up @@ -47,19 +49,80 @@ export function nextPrereleaseVersion(target: string, preid: string, existingCou
return `${target}-${preid}.${next}`;
}

/** Fetch all published versions of a package from the registry */
export async function fetchPublishedVersions(name: string, registry?: string): Promise<string[]> {
/** Published versions of a package, as listed by the registry */
export interface PublishedVersions {
versions: string[];
/**
* 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 <name>` 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: false | 'no-dist-tag' | 'lookup-failed';
}

type VersionsQueryResult = string[] | 'empty' | 'not-found' | 'failed';

/**
* Run `npm info <name> 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<VersionsQueryResult> {
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 (err) {
return /\bE404\b/.test(err instanceof Error ? err.message : String(err)) ? 'not-found' : 'failed';
}
if (!raw.trim()) return 'empty';
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 [];
return 'failed';
} catch {
return 'failed';
}
}

/**
* 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<PublishedVersions> {
const viaLatest = await queryVersions(name, opts.registry);
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: 'no-dist-tag' };
}

/** 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<boolean> {
const args = ['npm', 'info', `${name}@${version}`, 'version'];
if (registry) args.push('--registry', registry);
try {
return (await runArgsAsync(args)).trim() === version;
} catch {
// Package doesn't exist yet (first prerelease ever) or registry unreachable
return [];
return false;
}
}

Expand Down Expand Up @@ -87,12 +150,17 @@ export async function getPublishedPrereleaseState(
target: string,
preid: string,
rootDir: string,
distTag?: string,
): Promise<PublishedPrereleaseState> {
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,
Expand Down Expand Up @@ -150,7 +218,18 @@ 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) {
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} ${reason}, so its published versions couldn't be listed — ` +
`the prerelease counter starts at 0 and may collide.`,
);
}

if (state.stablePublished) {
warnings.push(
Expand Down
5 changes: 2 additions & 3 deletions packages/bumpy/src/core/snapshot.ts
Original file line number Diff line number Diff line change
@@ -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';

/**
Expand Down Expand Up @@ -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;
}
Expand Down
121 changes: 120 additions & 1 deletion packages/bumpy/test/core/prerelease.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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', () => {
Expand Down Expand Up @@ -162,3 +166,118 @@ 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 <name> 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: '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: 'no-dist-tag' });
});

test('a missing package is not unresolved', async () => {
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: '<html>bad gateway</html>' });
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' });
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: 'npm error code E404\nNo 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');
});

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');
});
});
});
Loading