From 6b3bee6b5d9367eb38b0f7e96c1cf2aee5efbcb3 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Thu, 18 Jun 2026 12:36:59 -0700 Subject: [PATCH] fix: label & link GitHub Packages releases correctly (#123) Packages published to a GitHub Packages registry (npm.pkg.github.com) were labelled "npm" in release notes and status/ci plan output, with a badge linking to a 404 npmjs.com page. buildPublishUrl now honours the configured registry: - npm targets on a GHP registry are labelled "GitHub Packages" and link to the package page under the repo (resolved from package.json repository or GITHUB_REPOSITORY) - other custom/private registries no longer emit a dead npmjs.com link Closes #123 --- .bumpy/github-packages-release-url.md | 5 + packages/bumpy/src/commands/publish.ts | 20 ++- packages/bumpy/src/commands/status.ts | 10 +- packages/bumpy/src/core/github-release.ts | 105 +++++++++++++-- .../bumpy/test/core/publish-recovery.test.ts | 123 ++++++++++++++++++ 5 files changed, 248 insertions(+), 15 deletions(-) create mode 100644 .bumpy/github-packages-release-url.md diff --git a/.bumpy/github-packages-release-url.md b/.bumpy/github-packages-release-url.md new file mode 100644 index 0000000..ea51750 --- /dev/null +++ b/.bumpy/github-packages-release-url.md @@ -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///pkgs/npm/`), 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). diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index 1d7f9ca..7567f3d 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -19,6 +19,9 @@ import { finalizeSupersededDrafts, composeReleaseBody, buildPublishUrl, + publishTargetLabel, + resolvePackageRegistry, + parseRepoSlug, isGhAvailable, getHeadSha, generateReleaseBody, @@ -274,6 +277,8 @@ async function runPublishFlow( // Determine publish targets for each package const publishTargetsByPkg = new Map(); + // Registry context per package, used to label targets and build correct release URLs. + const registryByPkg = new Map(); for (const release of toPublish) { const pkg = packages.get(release.name)!; const pkgConfig = pkg.bumpy || {}; @@ -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) @@ -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 = {}; 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, @@ -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; } diff --git a/packages/bumpy/src/commands/status.ts b/packages/bumpy/src/commands/status.ts index 8333ecd..3cd9850 100644 --- a/packages/bumpy/src/commands/status.ts +++ b/packages/bumpy/src/commands/status.ts @@ -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 { @@ -302,16 +303,17 @@ function getPublishTargets( pkg: WorkspacePackage | undefined, pkgConfig: Partial, _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; } diff --git a/packages/bumpy/src/core/github-release.ts b/packages/bumpy/src/core/github-release.ts index 1d68044..2181762 100644 --- a/packages/bumpy/src/core/github-release.ts +++ b/packages/bumpy/src/core/github-release.ts @@ -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 { @@ -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 { @@ -186,38 +188,123 @@ function serializeMetadata(metadata: ReleaseMetadata): string { export function formatPublishedToSection(targets: Record): 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 | 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]; diff --git a/packages/bumpy/test/core/publish-recovery.test.ts b/packages/bumpy/test/core/publish-recovery.test.ts index cd7a7fa..9045aa7 100644 --- a/packages/bumpy/test/core/publish-recovery.test.ts +++ b/packages/bumpy/test/core/publish-recovery.test.ts @@ -5,6 +5,10 @@ import { composeReleaseBody, updateReleaseBodyStatus, buildPublishUrl, + isGitHubPackagesRegistry, + publishTargetLabel, + resolvePackageRegistry, + parseRepoSlug, type ReleaseMetadata, } from '../../src/core/github-release.ts'; @@ -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'); + }); });