Skip to content

Commit 74a8aa6

Browse files
authored
fix: label & link GitHub Packages releases correctly (#123) (#124)
Closes #123 ## Problem When publishing to **GitHub Packages** (`npm.pkg.github.com`), bumpy labelled the target `npm` in the GitHub release notes and in `bumpy status` / `bumpy ci plan` output, and the "Published to" badge linked to a **non-existent npmjs.com page** (404). The actual publish went to the right registry — only the reported label/URL was wrong. Reported with a [minimal reproduction](https://github.com/Shtian/bumpy-github-packages-repro) by @Shtian. The `buildPublishUrl` `_registry` argument was unused — the registry was ignored at every reporting site. ## Fix `buildPublishUrl` now honours the configured registry (resolved from the bumpy `registry` config, falling back to `publishConfig.registry` in `package.json`): - **GitHub Packages** → target is labelled **GitHub Packages** and links to the package page under the repo: `https://github.com/<owner>/<repo>/pkgs/npm/<name>`. The `owner/repo` is resolved from the package's `repository` field, falling back to `GITHUB_REPOSITORY` in CI. - **Other custom/private registries** → no longer emit a dead npmjs.com link (no canonical browsable URL). - **Default npmjs registry / no registry** → unchanged. GitHub Packages has no per-version page, so the URL points at the package page (which lists versions). ### Sites touched - `buildPublishUrl` — honours `registry` + `repoSlug`; new `isGitHubPackagesRegistry`, `publishTargetLabel`, `resolvePackageRegistry`, `parseRepoSlug` helpers (core/github-release.ts) - Release notes "Published to" section — uses a per-target `label` (defaults to the target key) - `publish` command — computes registry/repo context per package and stamps label + URL into release metadata - `status` / `ci plan` JSON output — `publishTargets` now include `label` + `registry` ## Tests Added coverage for `buildPublishUrl` (GHP, default, custom-registry, missing-repo cases), `isGitHubPackagesRegistry`, `publishTargetLabel`, `resolvePackageRegistry`, `parseRepoSlug`, and label rendering in `formatPublishedToSection`. Full suite passes (327 tests); lint, format, and typecheck clean.
1 parent 9207029 commit 74a8aa6

5 files changed

Lines changed: 248 additions & 15 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@varlock/bumpy': patch
3+
---
4+
5+
Label and link npm targets published to GitHub Packages correctly. Packages publishing to a GitHub Packages registry (`npm.pkg.github.com`) were labelled `npm` in the GitHub release notes and `bumpy status`/`bumpy ci plan` output, with a "Published to" badge linking to a non-existent npmjs.com page (404). The configured registry is now honoured: such targets are labelled **GitHub Packages** and link to the package page under the repo (`https://github.com/<owner>/<repo>/pkgs/npm/<name>`), resolving the repo from the package's `repository` field or `GITHUB_REPOSITORY`. Other custom/private registries no longer emit a dead npmjs.com link. `buildPublishUrl` now honours its registry argument (previously the unused `_registry` param).

packages/bumpy/src/commands/publish.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import {
1919
finalizeSupersededDrafts,
2020
composeReleaseBody,
2121
buildPublishUrl,
22+
publishTargetLabel,
23+
resolvePackageRegistry,
24+
parseRepoSlug,
2225
isGhAvailable,
2326
getHeadSha,
2427
generateReleaseBody,
@@ -274,6 +277,8 @@ async function runPublishFlow(
274277

275278
// Determine publish targets for each package
276279
const publishTargetsByPkg = new Map<string, string[]>();
280+
// Registry context per package, used to label targets and build correct release URLs.
281+
const registryByPkg = new Map<string, { registry?: string; repoSlug?: string }>();
277282
for (const release of toPublish) {
278283
const pkg = packages.get(release.name)!;
279284
const pkgConfig = pkg.bumpy || {};
@@ -284,6 +289,10 @@ async function runPublishFlow(
284289
targets.push('npm');
285290
}
286291
publishTargetsByPkg.set(release.name, targets);
292+
registryByPkg.set(release.name, {
293+
registry: resolvePackageRegistry(pkg, pkgConfig),
294+
repoSlug: parseRepoSlug(pkg.packageJson.repository) ?? process.env.GITHUB_REPOSITORY,
295+
});
287296
}
288297

289298
// For each package, set up draft releases (if gh is available and not dry run)
@@ -319,9 +328,11 @@ async function runPublishFlow(
319328
? await generateReleaseBody(release, releasePlan.bumpFiles, formatter)
320329
: buildReleaseBody(release, releasePlan.bumpFiles);
321330

331+
const { registry } = registryByPkg.get(release.name) || {};
322332
const initialTargets: Record<string, PublishTargetState> = {};
323333
for (const t of targets) {
324-
initialTargets[t] = { status: 'pending' };
334+
const label = publishTargetLabel(t, registry);
335+
initialTargets[t] = { status: 'pending', ...(label !== t ? { label } : {}) };
325336
}
326337
const metadata: ReleaseMetadata = {
327338
version: release.newVersion,
@@ -431,23 +442,28 @@ async function runPublishFlow(
431442
const published = result.published.find((p) => p.name === release.name);
432443
const failed = result.failed.find((f) => f.name === release.name);
433444

445+
const { registry, repoSlug } = registryByPkg.get(release.name) || {};
434446
let changed = false;
435447
for (const targetName of targets) {
436448
// Skip already-succeeded targets
437449
if (info.metadata.targets[targetName]?.status === 'success') continue;
438450

439451
if (published) {
452+
const label = publishTargetLabel(targetName, registry);
440453
info.metadata.targets[targetName] = {
441454
status: 'success',
442455
publishedAt: new Date().toISOString(),
443-
url: buildPublishUrl(release.name, release.newVersion, targetName),
456+
url: buildPublishUrl(release.name, release.newVersion, targetName, { registry, repoSlug }),
457+
...(label !== targetName ? { label } : {}),
444458
};
445459
changed = true;
446460
} else if (failed) {
461+
const label = publishTargetLabel(targetName, registry);
447462
info.metadata.targets[targetName] = {
448463
status: 'failed',
449464
error: failed.error,
450465
lastAttempt: new Date().toISOString(),
466+
...(label !== targetName ? { label } : {}),
451467
};
452468
changed = true;
453469
}

packages/bumpy/src/commands/status.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { assembleReleasePlan } from '../core/release-plan.ts';
77
import { getCurrentBranch, getChangedFiles } from '../core/git.ts';
88
import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts';
99
import { buildChannelReleasePlan } from '../core/prerelease.ts';
10+
import { publishTargetLabel, resolvePackageRegistry } from '../core/github-release.ts';
1011
import type { BumpFile, BumpyConfig, PackageConfig, PlannedRelease, WorkspacePackage } from '../types.ts';
1112

1213
interface StatusOptions {
@@ -302,16 +303,17 @@ function getPublishTargets(
302303
pkg: WorkspacePackage | undefined,
303304
pkgConfig: Partial<PackageConfig>,
304305
_config: BumpyConfig,
305-
): Array<{ type: string }> {
306+
): Array<{ type: string; label: string; registry?: string }> {
306307
if (!pkg) return [];
307308
// Private packages with no custom command won't publish
308309
if (pkg.private && !pkgConfig.publishCommand) return [];
309-
const targets: Array<{ type: string }> = [];
310+
const targets: Array<{ type: string; label: string; registry?: string }> = [];
310311
if (pkgConfig.publishCommand) {
311-
targets.push({ type: 'custom' });
312+
targets.push({ type: 'custom', label: 'custom' });
312313
}
313314
if (!pkgConfig.publishCommand && !pkgConfig.skipNpmPublish) {
314-
targets.push({ type: 'npm' });
315+
const registry = resolvePackageRegistry(pkg, pkgConfig);
316+
targets.push({ type: 'npm', label: publishTargetLabel('npm', registry), ...(registry ? { registry } : {}) });
315317
}
316318
return targets;
317319
}

packages/bumpy/src/core/github-release.ts

Lines changed: 96 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { tryRunArgs, runArgsAsync } from '../utils/shell.ts';
22
import { log } from '../utils/logger.ts';
33
import { generateChangelogEntry } from './changelog.ts';
44
import type { ChangelogFormatter } from './changelog.ts';
5-
import type { PlannedRelease, BumpFile } from '../types.ts';
5+
import type { PlannedRelease, BumpFile, PackageConfig, WorkspacePackage } from '../types.ts';
66

77
/** Get the current HEAD commit SHA */
88
export function getHeadSha(rootDir: string): string | null {
@@ -148,6 +148,8 @@ export interface PublishTargetState {
148148
reason?: string;
149149
supersededBy?: string;
150150
url?: string;
151+
/** Human-readable label, e.g. "GitHub Packages" for npm targets on a GHP registry. Falls back to the target key. */
152+
label?: string;
151153
}
152154

153155
export interface ReleaseMetadata {
@@ -186,38 +188,123 @@ function serializeMetadata(metadata: ReleaseMetadata): string {
186188
export function formatPublishedToSection(targets: Record<string, PublishTargetState>): string {
187189
const lines: string[] = ['#### Published to'];
188190
for (const [name, state] of Object.entries(targets)) {
191+
const label = state.label ?? name;
189192
switch (state.status) {
190193
case 'success':
191-
lines.push(state.url ? `- ✅ [${name}](${state.url})` : `- ✅ ${name}`);
194+
lines.push(state.url ? `- ✅ [${label}](${state.url})` : `- ✅ ${label}`);
192195
break;
193196
case 'failed':
194-
lines.push(`- ❌ ${name} — will retry on next CI run`);
197+
lines.push(`- ❌ ${label} — will retry on next CI run`);
195198
break;
196199
case 'skipped':
197200
lines.push(
198201
state.supersededBy
199-
? `- ⏭️ ${name} — skipped (superseded by ${state.supersededBy})`
200-
: `- ⏭️ ${name} — skipped`,
202+
? `- ⏭️ ${label} — skipped (superseded by ${state.supersededBy})`
203+
: `- ⏭️ ${label} — skipped`,
201204
);
202205
break;
203206
case 'pending':
204-
lines.push(`- ⏳ ${name}`);
207+
lines.push(`- ⏳ ${label}`);
205208
break;
206209
}
207210
}
208211
return lines.join('\n');
209212
}
210213

211-
/** Build a URL for a published package on a registry */
214+
const GITHUB_PACKAGES_HOST = 'npm.pkg.github.com';
215+
const DEFAULT_NPM_HOST = 'registry.npmjs.org';
216+
217+
/** Extract the host from a registry URL, tolerating missing protocols and trailing slashes. */
218+
function registryHost(registry: string): string {
219+
try {
220+
return new URL(registry).host;
221+
} catch {
222+
try {
223+
return new URL(`https://${registry}`).host;
224+
} catch {
225+
return '';
226+
}
227+
}
228+
}
229+
230+
/** Whether a registry URL points at GitHub Packages (npm.pkg.github.com). */
231+
export function isGitHubPackagesRegistry(registry?: string): boolean {
232+
return !!registry && registryHost(registry) === GITHUB_PACKAGES_HOST;
233+
}
234+
235+
/** Whether a registry URL is the public npmjs.com registry (the default). */
236+
function isDefaultNpmRegistry(registry?: string): boolean {
237+
return !registry || registryHost(registry) === DEFAULT_NPM_HOST;
238+
}
239+
240+
/**
241+
* Human-readable label for a publish target, accounting for the configured registry.
242+
* An `npm`-type target on a GitHub Packages registry is labelled "GitHub Packages".
243+
*/
244+
export function publishTargetLabel(targetType: string, registry?: string): string {
245+
if (targetType === 'npm' && isGitHubPackagesRegistry(registry)) {
246+
return 'GitHub Packages';
247+
}
248+
return targetType;
249+
}
250+
251+
/**
252+
* Resolve the effective publish registry for a package: the bumpy `registry` config
253+
* wins, falling back to npm-native `publishConfig.registry` in package.json.
254+
*/
255+
export function resolvePackageRegistry(
256+
pkg: WorkspacePackage | undefined,
257+
pkgConfig: Partial<PackageConfig> | undefined,
258+
): string | undefined {
259+
if (pkgConfig?.registry) return pkgConfig.registry;
260+
const publishConfig = pkg?.packageJson?.publishConfig;
261+
if (publishConfig && typeof publishConfig === 'object' && 'registry' in publishConfig) {
262+
const registry = (publishConfig as { registry?: unknown }).registry;
263+
if (typeof registry === 'string' && registry) return registry;
264+
}
265+
return undefined;
266+
}
267+
268+
/** Parse an "owner/repo" slug from a package.json `repository` field (string or object form). */
269+
export function parseRepoSlug(repository: unknown): string | undefined {
270+
const url =
271+
typeof repository === 'string'
272+
? repository
273+
: repository && typeof repository === 'object' && 'url' in repository
274+
? String((repository as { url?: unknown }).url ?? '')
275+
: '';
276+
if (!url) return undefined;
277+
// Handles git+https://github.com/owner/repo.git, git@github.com:owner/repo.git, https://github.com/owner/repo
278+
const match = url.match(/github\.com[/:]([^/]+)\/([^/#]+?)(?:\.git)?\/?(?:[#?].*)?$/);
279+
return match ? `${match[1]}/${match[2]}` : undefined;
280+
}
281+
282+
export interface BuildPublishUrlOptions {
283+
/** Configured registry for the package (bumpy config or publishConfig). */
284+
registry?: string;
285+
/** "owner/repo" slug, used to build GitHub Packages URLs. */
286+
repoSlug?: string;
287+
}
288+
289+
/** Build a browsable URL for a published package, honouring the configured registry. */
212290
export function buildPublishUrl(
213291
name: string,
214292
version: string,
215293
targetType: string,
216-
_registry?: string,
294+
opts: BuildPublishUrlOptions = {},
217295
): string | undefined {
218296
switch (targetType) {
219-
case 'npm':
297+
case 'npm': {
298+
if (isGitHubPackagesRegistry(opts.registry)) {
299+
// GitHub Packages has no per-version page; link to the package page under the repo.
300+
if (!opts.repoSlug) return undefined;
301+
const unscoped = name.includes('/') ? name.slice(name.indexOf('/') + 1) : name;
302+
return `https://github.com/${opts.repoSlug}/pkgs/npm/${unscoped}`;
303+
}
304+
// Custom/private registries have no canonical browsable URL — avoid a dead npmjs.com link.
305+
if (!isDefaultNpmRegistry(opts.registry)) return undefined;
220306
return `https://www.npmjs.com/package/${name}/v/${version}`;
307+
}
221308
case 'jsr': {
222309
// JSR uses @scope/name format
223310
const parts = name.startsWith('@') ? name.slice(1).split('/') : [name];

packages/bumpy/test/core/publish-recovery.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import {
55
composeReleaseBody,
66
updateReleaseBodyStatus,
77
buildPublishUrl,
8+
isGitHubPackagesRegistry,
9+
publishTargetLabel,
10+
resolvePackageRegistry,
11+
parseRepoSlug,
812
type ReleaseMetadata,
913
} from '../../src/core/github-release.ts';
1014

@@ -213,4 +217,123 @@ describe('buildPublishUrl', () => {
213217
test('returns undefined for custom target', () => {
214218
expect(buildPublishUrl('pkg', '1.0.0', 'custom')).toBeUndefined();
215219
});
220+
221+
test('builds npm URL when registry is the default npmjs registry', () => {
222+
expect(buildPublishUrl('@varlock/bumpy', '1.9.2', 'npm', { registry: 'https://registry.npmjs.org/' })).toBe(
223+
'https://www.npmjs.com/package/@varlock/bumpy/v/1.9.2',
224+
);
225+
});
226+
227+
test('builds a GitHub Packages URL for a GHP registry', () => {
228+
expect(
229+
buildPublishUrl('@shtian/my-pkg', '1.3.0', 'npm', {
230+
registry: 'https://npm.pkg.github.com/',
231+
repoSlug: 'Shtian/bumpy-github-packages-repro',
232+
}),
233+
).toBe('https://github.com/Shtian/bumpy-github-packages-repro/pkgs/npm/my-pkg');
234+
});
235+
236+
test('GitHub Packages URL works for unscoped packages', () => {
237+
expect(
238+
buildPublishUrl('my-pkg', '1.0.0', 'npm', {
239+
registry: 'https://npm.pkg.github.com/',
240+
repoSlug: 'owner/repo',
241+
}),
242+
).toBe('https://github.com/owner/repo/pkgs/npm/my-pkg');
243+
});
244+
245+
test('returns undefined for a GHP registry without a known repo', () => {
246+
expect(
247+
buildPublishUrl('@shtian/my-pkg', '1.3.0', 'npm', { registry: 'https://npm.pkg.github.com/' }),
248+
).toBeUndefined();
249+
});
250+
251+
test('returns undefined (no dead npmjs link) for an unknown custom registry', () => {
252+
expect(buildPublishUrl('@acme/pkg', '1.0.0', 'npm', { registry: 'https://npm.acme.internal/' })).toBeUndefined();
253+
});
254+
});
255+
256+
describe('isGitHubPackagesRegistry', () => {
257+
test('detects GitHub Packages registries', () => {
258+
expect(isGitHubPackagesRegistry('https://npm.pkg.github.com/')).toBe(true);
259+
expect(isGitHubPackagesRegistry('npm.pkg.github.com')).toBe(true);
260+
});
261+
262+
test('rejects other registries', () => {
263+
expect(isGitHubPackagesRegistry('https://registry.npmjs.org/')).toBe(false);
264+
expect(isGitHubPackagesRegistry(undefined)).toBe(false);
265+
expect(isGitHubPackagesRegistry('https://npm.pkg.github.com.evil.com/')).toBe(false);
266+
});
267+
});
268+
269+
describe('publishTargetLabel', () => {
270+
test('labels npm on a GHP registry as GitHub Packages', () => {
271+
expect(publishTargetLabel('npm', 'https://npm.pkg.github.com/')).toBe('GitHub Packages');
272+
});
273+
274+
test('keeps npm label for the default registry', () => {
275+
expect(publishTargetLabel('npm', undefined)).toBe('npm');
276+
expect(publishTargetLabel('npm', 'https://registry.npmjs.org/')).toBe('npm');
277+
});
278+
279+
test('passes through non-npm target types', () => {
280+
expect(publishTargetLabel('jsr', 'https://npm.pkg.github.com/')).toBe('jsr');
281+
expect(publishTargetLabel('custom', undefined)).toBe('custom');
282+
});
283+
});
284+
285+
describe('resolvePackageRegistry', () => {
286+
const pkg = (publishConfig?: unknown) => ({ packageJson: publishConfig ? { publishConfig } : {} }) as never;
287+
288+
test('prefers bumpy config registry', () => {
289+
expect(resolvePackageRegistry(pkg({ registry: 'https://b/' }), { registry: 'https://a/' })).toBe('https://a/');
290+
});
291+
292+
test('falls back to package.json publishConfig.registry', () => {
293+
expect(resolvePackageRegistry(pkg({ registry: 'https://npm.pkg.github.com/' }), {})).toBe(
294+
'https://npm.pkg.github.com/',
295+
);
296+
});
297+
298+
test('returns undefined when no registry configured', () => {
299+
expect(resolvePackageRegistry(pkg(), {})).toBeUndefined();
300+
expect(resolvePackageRegistry(undefined, undefined)).toBeUndefined();
301+
});
302+
});
303+
304+
describe('parseRepoSlug', () => {
305+
test('parses git+https url with .git suffix', () => {
306+
expect(parseRepoSlug('git+https://github.com/Shtian/bumpy-github-packages-repro.git')).toBe(
307+
'Shtian/bumpy-github-packages-repro',
308+
);
309+
});
310+
311+
test('parses object form and ssh url', () => {
312+
expect(parseRepoSlug({ url: 'git@github.com:owner/repo.git' })).toBe('owner/repo');
313+
expect(parseRepoSlug({ type: 'git', url: 'https://github.com/owner/repo' })).toBe('owner/repo');
314+
});
315+
316+
test('returns undefined for non-github or missing repository', () => {
317+
expect(parseRepoSlug('https://gitlab.com/owner/repo.git')).toBeUndefined();
318+
expect(parseRepoSlug(undefined)).toBeUndefined();
319+
expect(parseRepoSlug({})).toBeUndefined();
320+
});
321+
});
322+
323+
describe('formatPublishedToSection with labels', () => {
324+
test('uses the target label when present', () => {
325+
const result = formatPublishedToSection({
326+
npm: {
327+
status: 'success',
328+
label: 'GitHub Packages',
329+
url: 'https://github.com/owner/repo/pkgs/npm/my-pkg',
330+
},
331+
});
332+
expect(result).toContain('- ✅ [GitHub Packages](https://github.com/owner/repo/pkgs/npm/my-pkg)');
333+
});
334+
335+
test('falls back to the target key when no label', () => {
336+
const result = formatPublishedToSection({ npm: { status: 'pending' } });
337+
expect(result).toContain('- ⏳ npm');
338+
});
216339
});

0 commit comments

Comments
 (0)