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/github-packages-release-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@varlock/bumpy': patch
---

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).
20 changes: 18 additions & 2 deletions packages/bumpy/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import {
finalizeSupersededDrafts,
composeReleaseBody,
buildPublishUrl,
publishTargetLabel,
resolvePackageRegistry,
parseRepoSlug,
isGhAvailable,
getHeadSha,
generateReleaseBody,
Expand Down Expand Up @@ -274,6 +277,8 @@ async function runPublishFlow(

// Determine publish targets for each package
const publishTargetsByPkg = new Map<string, string[]>();
// Registry context per package, used to label targets and build correct release URLs.
const registryByPkg = new Map<string, { registry?: string; repoSlug?: string }>();
for (const release of toPublish) {
const pkg = packages.get(release.name)!;
const pkgConfig = pkg.bumpy || {};
Expand All @@ -284,6 +289,10 @@ async function runPublishFlow(
targets.push('npm');
}
publishTargetsByPkg.set(release.name, targets);
registryByPkg.set(release.name, {
registry: resolvePackageRegistry(pkg, pkgConfig),
repoSlug: parseRepoSlug(pkg.packageJson.repository) ?? process.env.GITHUB_REPOSITORY,
});
}

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

const { registry } = registryByPkg.get(release.name) || {};
const initialTargets: Record<string, PublishTargetState> = {};
for (const t of targets) {
initialTargets[t] = { status: 'pending' };
const label = publishTargetLabel(t, registry);
initialTargets[t] = { status: 'pending', ...(label !== t ? { label } : {}) };
}
const metadata: ReleaseMetadata = {
version: release.newVersion,
Expand Down Expand Up @@ -431,23 +442,28 @@ async function runPublishFlow(
const published = result.published.find((p) => p.name === release.name);
const failed = result.failed.find((f) => f.name === release.name);

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

if (published) {
const label = publishTargetLabel(targetName, registry);
info.metadata.targets[targetName] = {
status: 'success',
publishedAt: new Date().toISOString(),
url: buildPublishUrl(release.name, release.newVersion, targetName),
url: buildPublishUrl(release.name, release.newVersion, targetName, { registry, repoSlug }),
...(label !== targetName ? { label } : {}),
};
changed = true;
} else if (failed) {
const label = publishTargetLabel(targetName, registry);
info.metadata.targets[targetName] = {
status: 'failed',
error: failed.error,
lastAttempt: new Date().toISOString(),
...(label !== targetName ? { label } : {}),
};
changed = true;
}
Expand Down
10 changes: 6 additions & 4 deletions packages/bumpy/src/commands/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { assembleReleasePlan } from '../core/release-plan.ts';
import { getCurrentBranch, getChangedFiles } from '../core/git.ts';
import { channelNames, resolveActiveChannel, type ResolvedChannel } from '../core/channels.ts';
import { buildChannelReleasePlan } from '../core/prerelease.ts';
import { publishTargetLabel, resolvePackageRegistry } from '../core/github-release.ts';
import type { BumpFile, BumpyConfig, PackageConfig, PlannedRelease, WorkspacePackage } from '../types.ts';

interface StatusOptions {
Expand Down Expand Up @@ -302,16 +303,17 @@ function getPublishTargets(
pkg: WorkspacePackage | undefined,
pkgConfig: Partial<PackageConfig>,
_config: BumpyConfig,
): Array<{ type: string }> {
): Array<{ type: string; label: string; registry?: string }> {
if (!pkg) return [];
// Private packages with no custom command won't publish
if (pkg.private && !pkgConfig.publishCommand) return [];
const targets: Array<{ type: string }> = [];
const targets: Array<{ type: string; label: string; registry?: string }> = [];
if (pkgConfig.publishCommand) {
targets.push({ type: 'custom' });
targets.push({ type: 'custom', label: 'custom' });
}
if (!pkgConfig.publishCommand && !pkgConfig.skipNpmPublish) {
targets.push({ type: 'npm' });
const registry = resolvePackageRegistry(pkg, pkgConfig);
targets.push({ type: 'npm', label: publishTargetLabel('npm', registry), ...(registry ? { registry } : {}) });
}
return targets;
}
105 changes: 96 additions & 9 deletions packages/bumpy/src/core/github-release.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { tryRunArgs, runArgsAsync } from '../utils/shell.ts';
import { log } from '../utils/logger.ts';
import { generateChangelogEntry } from './changelog.ts';
import type { ChangelogFormatter } from './changelog.ts';
import type { PlannedRelease, BumpFile } from '../types.ts';
import type { PlannedRelease, BumpFile, PackageConfig, WorkspacePackage } from '../types.ts';

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

export interface ReleaseMetadata {
Expand Down Expand Up @@ -186,38 +188,123 @@ function serializeMetadata(metadata: ReleaseMetadata): string {
export function formatPublishedToSection(targets: Record<string, PublishTargetState>): string {
const lines: string[] = ['#### Published to'];
for (const [name, state] of Object.entries(targets)) {
const label = state.label ?? name;
switch (state.status) {
case 'success':
lines.push(state.url ? `- ✅ [${name}](${state.url})` : `- ✅ ${name}`);
lines.push(state.url ? `- ✅ [${label}](${state.url})` : `- ✅ ${label}`);
break;
case 'failed':
lines.push(`- ❌ ${name} — will retry on next CI run`);
lines.push(`- ❌ ${label} — will retry on next CI run`);
break;
case 'skipped':
lines.push(
state.supersededBy
? `- ⏭️ ${name} — skipped (superseded by ${state.supersededBy})`
: `- ⏭️ ${name} — skipped`,
? `- ⏭️ ${label} — skipped (superseded by ${state.supersededBy})`
: `- ⏭️ ${label} — skipped`,
);
break;
case 'pending':
lines.push(`- ⏳ ${name}`);
lines.push(`- ⏳ ${label}`);
break;
}
}
return lines.join('\n');
}

/** Build a URL for a published package on a registry */
const GITHUB_PACKAGES_HOST = 'npm.pkg.github.com';
const DEFAULT_NPM_HOST = 'registry.npmjs.org';

/** Extract the host from a registry URL, tolerating missing protocols and trailing slashes. */
function registryHost(registry: string): string {
try {
return new URL(registry).host;
} catch {
try {
return new URL(`https://${registry}`).host;
} catch {
return '';
}
}
}

/** Whether a registry URL points at GitHub Packages (npm.pkg.github.com). */
export function isGitHubPackagesRegistry(registry?: string): boolean {
return !!registry && registryHost(registry) === GITHUB_PACKAGES_HOST;
}

/** Whether a registry URL is the public npmjs.com registry (the default). */
function isDefaultNpmRegistry(registry?: string): boolean {
return !registry || registryHost(registry) === DEFAULT_NPM_HOST;
}

/**
* Human-readable label for a publish target, accounting for the configured registry.
* An `npm`-type target on a GitHub Packages registry is labelled "GitHub Packages".
*/
export function publishTargetLabel(targetType: string, registry?: string): string {
if (targetType === 'npm' && isGitHubPackagesRegistry(registry)) {
return 'GitHub Packages';
}
return targetType;
}

/**
* Resolve the effective publish registry for a package: the bumpy `registry` config
* wins, falling back to npm-native `publishConfig.registry` in package.json.
*/
export function resolvePackageRegistry(
pkg: WorkspacePackage | undefined,
pkgConfig: Partial<PackageConfig> | undefined,
): string | undefined {
if (pkgConfig?.registry) return pkgConfig.registry;
const publishConfig = pkg?.packageJson?.publishConfig;
if (publishConfig && typeof publishConfig === 'object' && 'registry' in publishConfig) {
const registry = (publishConfig as { registry?: unknown }).registry;
if (typeof registry === 'string' && registry) return registry;
}
return undefined;
}

/** Parse an "owner/repo" slug from a package.json `repository` field (string or object form). */
export function parseRepoSlug(repository: unknown): string | undefined {
const url =
typeof repository === 'string'
? repository
: repository && typeof repository === 'object' && 'url' in repository
? String((repository as { url?: unknown }).url ?? '')
: '';
if (!url) return undefined;
// Handles git+https://github.com/owner/repo.git, git@github.com:owner/repo.git, https://github.com/owner/repo
const match = url.match(/github\.com[/:]([^/]+)\/([^/#]+?)(?:\.git)?\/?(?:[#?].*)?$/);
return match ? `${match[1]}/${match[2]}` : undefined;
}

export interface BuildPublishUrlOptions {
/** Configured registry for the package (bumpy config or publishConfig). */
registry?: string;
/** "owner/repo" slug, used to build GitHub Packages URLs. */
repoSlug?: string;
}

/** Build a browsable URL for a published package, honouring the configured registry. */
export function buildPublishUrl(
name: string,
version: string,
targetType: string,
_registry?: string,
opts: BuildPublishUrlOptions = {},
): string | undefined {
switch (targetType) {
case 'npm':
case 'npm': {
if (isGitHubPackagesRegistry(opts.registry)) {
// GitHub Packages has no per-version page; link to the package page under the repo.
if (!opts.repoSlug) return undefined;
const unscoped = name.includes('/') ? name.slice(name.indexOf('/') + 1) : name;
return `https://github.com/${opts.repoSlug}/pkgs/npm/${unscoped}`;
}
// Custom/private registries have no canonical browsable URL — avoid a dead npmjs.com link.
if (!isDefaultNpmRegistry(opts.registry)) return undefined;
return `https://www.npmjs.com/package/${name}/v/${version}`;
}
case 'jsr': {
// JSR uses @scope/name format
const parts = name.startsWith('@') ? name.slice(1).split('/') : [name];
Expand Down
123 changes: 123 additions & 0 deletions packages/bumpy/test/core/publish-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
composeReleaseBody,
updateReleaseBodyStatus,
buildPublishUrl,
isGitHubPackagesRegistry,
publishTargetLabel,
resolvePackageRegistry,
parseRepoSlug,
type ReleaseMetadata,
} from '../../src/core/github-release.ts';

Expand Down Expand Up @@ -213,4 +217,123 @@ describe('buildPublishUrl', () => {
test('returns undefined for custom target', () => {
expect(buildPublishUrl('pkg', '1.0.0', 'custom')).toBeUndefined();
});

test('builds npm URL when registry is the default npmjs registry', () => {
expect(buildPublishUrl('@varlock/bumpy', '1.9.2', 'npm', { registry: 'https://registry.npmjs.org/' })).toBe(
'https://www.npmjs.com/package/@varlock/bumpy/v/1.9.2',
);
});

test('builds a GitHub Packages URL for a GHP registry', () => {
expect(
buildPublishUrl('@shtian/my-pkg', '1.3.0', 'npm', {
registry: 'https://npm.pkg.github.com/',
repoSlug: 'Shtian/bumpy-github-packages-repro',
}),
).toBe('https://github.com/Shtian/bumpy-github-packages-repro/pkgs/npm/my-pkg');
});

test('GitHub Packages URL works for unscoped packages', () => {
expect(
buildPublishUrl('my-pkg', '1.0.0', 'npm', {
registry: 'https://npm.pkg.github.com/',
repoSlug: 'owner/repo',
}),
).toBe('https://github.com/owner/repo/pkgs/npm/my-pkg');
});

test('returns undefined for a GHP registry without a known repo', () => {
expect(
buildPublishUrl('@shtian/my-pkg', '1.3.0', 'npm', { registry: 'https://npm.pkg.github.com/' }),
).toBeUndefined();
});

test('returns undefined (no dead npmjs link) for an unknown custom registry', () => {
expect(buildPublishUrl('@acme/pkg', '1.0.0', 'npm', { registry: 'https://npm.acme.internal/' })).toBeUndefined();
});
});

describe('isGitHubPackagesRegistry', () => {
test('detects GitHub Packages registries', () => {
expect(isGitHubPackagesRegistry('https://npm.pkg.github.com/')).toBe(true);
expect(isGitHubPackagesRegistry('npm.pkg.github.com')).toBe(true);
});

test('rejects other registries', () => {
expect(isGitHubPackagesRegistry('https://registry.npmjs.org/')).toBe(false);
expect(isGitHubPackagesRegistry(undefined)).toBe(false);
expect(isGitHubPackagesRegistry('https://npm.pkg.github.com.evil.com/')).toBe(false);
});
});

describe('publishTargetLabel', () => {
test('labels npm on a GHP registry as GitHub Packages', () => {
expect(publishTargetLabel('npm', 'https://npm.pkg.github.com/')).toBe('GitHub Packages');
});

test('keeps npm label for the default registry', () => {
expect(publishTargetLabel('npm', undefined)).toBe('npm');
expect(publishTargetLabel('npm', 'https://registry.npmjs.org/')).toBe('npm');
});

test('passes through non-npm target types', () => {
expect(publishTargetLabel('jsr', 'https://npm.pkg.github.com/')).toBe('jsr');
expect(publishTargetLabel('custom', undefined)).toBe('custom');
});
});

describe('resolvePackageRegistry', () => {
const pkg = (publishConfig?: unknown) => ({ packageJson: publishConfig ? { publishConfig } : {} }) as never;

test('prefers bumpy config registry', () => {
expect(resolvePackageRegistry(pkg({ registry: 'https://b/' }), { registry: 'https://a/' })).toBe('https://a/');
});

test('falls back to package.json publishConfig.registry', () => {
expect(resolvePackageRegistry(pkg({ registry: 'https://npm.pkg.github.com/' }), {})).toBe(
'https://npm.pkg.github.com/',
);
});

test('returns undefined when no registry configured', () => {
expect(resolvePackageRegistry(pkg(), {})).toBeUndefined();
expect(resolvePackageRegistry(undefined, undefined)).toBeUndefined();
});
});

describe('parseRepoSlug', () => {
test('parses git+https url with .git suffix', () => {
expect(parseRepoSlug('git+https://github.com/Shtian/bumpy-github-packages-repro.git')).toBe(
'Shtian/bumpy-github-packages-repro',
);
});

test('parses object form and ssh url', () => {
expect(parseRepoSlug({ url: 'git@github.com:owner/repo.git' })).toBe('owner/repo');
expect(parseRepoSlug({ type: 'git', url: 'https://github.com/owner/repo' })).toBe('owner/repo');
});

test('returns undefined for non-github or missing repository', () => {
expect(parseRepoSlug('https://gitlab.com/owner/repo.git')).toBeUndefined();
expect(parseRepoSlug(undefined)).toBeUndefined();
expect(parseRepoSlug({})).toBeUndefined();
});
});

describe('formatPublishedToSection with labels', () => {
test('uses the target label when present', () => {
const result = formatPublishedToSection({
npm: {
status: 'success',
label: 'GitHub Packages',
url: 'https://github.com/owner/repo/pkgs/npm/my-pkg',
},
});
expect(result).toContain('- ✅ [GitHub Packages](https://github.com/owner/repo/pkgs/npm/my-pkg)');
});

test('falls back to the target key when no label', () => {
const result = formatPublishedToSection({ npm: { status: 'pending' } });
expect(result).toContain('- ⏳ npm');
});
});