diff --git a/.bumpy/publish-recovery.md b/.bumpy/publish-recovery.md new file mode 100644 index 0000000..774152f --- /dev/null +++ b/.bumpy/publish-recovery.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +feat: publish recovery with draft GitHub releases and removal of aggregate release mode diff --git a/docs/comparisons.md b/docs/comparisons.md new file mode 100644 index 0000000..2183204 --- /dev/null +++ b/docs/comparisons.md @@ -0,0 +1,157 @@ +# Comparisons with Other Tools + +There are several great tools in the release management space, each with different design philosophies and trade-offs. This page gives an honest comparison of bumpy with the most popular alternatives, focusing on where each tool shines and where it falls short. + +> Bumpy's core design choice is **explicit bump files per PR** — small markdown files that declare which packages are changing, at what level, and with a human-written description. This gives precise per-package control and produces consumer-facing changelogs, but requires slightly more work per PR than label- or commit-based approaches. Many of the differences below stem from this choice. + +--- + +## Changesets + +[Changesets](https://github.com/changesets/changesets) is the most direct comparison — bumpy uses the same bump-file-per-PR model and is designed as a successor to it. Changesets is mature, widely adopted, and battle-tested across many large monorepos. + +**Where changesets shines:** + +- Proven at scale with years of production use across the ecosystem +- Large community with extensive documentation and third-party integrations +- Stable, well-understood behavior + +**Where bumpy differs:** + +- **Dependency propagation** — changesets hardcodes aggressive peer dep behavior (a minor bump can trigger major bumps on dependents). Bumpy uses a [configurable three-phase algorithm](./version-propagation.md) with sensible defaults. +- **Workspace protocols** — changesets uses `npm publish` even in pnpm/yarn workspaces, so `workspace:^` and `catalog:` protocols may not be resolved correctly. Bumpy resolves these before publishing. +- **Custom publish commands** — changesets is locked to `npm publish`. Bumpy supports per-package custom commands for VSCode extensions, Docker images, JSR, etc. +- **CI setup** — changesets requires a [GitHub App](https://github.com/apps/changeset-bot) and a [separate GitHub Action](https://github.com/changesets/action). Bumpy uses two CLI commands (`bumpy ci check` + `bumpy ci release`) that run directly in your workflows. +- **Non-interactive CLI** — `bumpy add` works fully non-interactively, which is important for CI/CD and AI-assisted workflows. + +For a detailed breakdown with links to specific changesets issues, see [Differences from Changesets](./differences-from-changesets.md). + +--- + +## semantic-release + +[semantic-release](https://github.com/semantic-release/semantic-release) is the most popular fully-automated release tool. It analyzes commit messages (typically [Angular convention](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-format.md)) to determine version bumps, generate changelogs, and publish — all without human intervention after merge. + +**Where semantic-release shines:** + +- Truly zero-touch releases — no manual step between merge and publish +- Rich plugin ecosystem for different registries, CI providers, and changelog formats +- Enforces consistent commit discipline across teams +- Well-suited for single-package repos with a linear commit history + +**Where bumpy differs:** + +- **Monorepo support** — semantic-release was designed for single packages. Monorepo support exists via plugins like [multi-semantic-release](https://github.com/dhoulb/multi-semantic-release), but it's not first-class and can be fragile. +- **Per-PR granularity** — with semantic-release, a squash-merged PR produces a single commit, so the version bump is determined by the commit message of the squash. If a PR touches multiple packages at different levels, this is hard to express. Bump files let you specify different bump levels for different packages in a single PR. +- **Changelog quality** — semantic-release changelogs are derived from commit messages, which tend to be written for developers. Bump files let you write descriptions aimed at package consumers. +- **Review before release** — bumpy's release PR gives maintainers a chance to review the full release plan before it goes out. semantic-release publishes immediately on merge with no review step (by design — this is a feature for some teams). +- **Commit convention requirement** — semantic-release requires strict commit message formatting. Bumpy works with any commit style (though `bumpy generate` can optionally derive bump files from conventional commits). + +**When to choose semantic-release:** You have a single-package repo (or a small set of independent packages), your team is disciplined about commit conventions, and you want fully hands-off publishing with no release PR step. + +--- + +## release-please + +[release-please](https://github.com/googleapis/release-please) is Google's release automation tool. Like semantic-release, it uses conventional commits — but instead of publishing immediately, it maintains a release PR that accumulates changes. Merging the PR triggers tagging and GitHub release creation. + +**Where release-please shines:** + +- Broad language support — 18+ ecosystems (Node, Python, Java, Go, Rust, Ruby, PHP, etc.) with language-specific version file updates +- Release PR model gives maintainers a review step before release (similar to bumpy) +- Squash-merge friendly with good linear history support +- Manual version overrides via `Release-As: x.y.z` commit footer +- Backed by Google with active maintenance + +**Where bumpy differs:** + +- **Commit convention requirement** — release-please requires conventional commits for version determination. Bumpy doesn't require any commit convention. +- **Per-package control in PRs** — release-please determines bump levels from commits, so a single PR can't easily express "minor for package A, patch for package B." Bump files make this explicit. +- **Publishing** — release-please deliberately does not handle publishing; you need separate CI steps for that. Bumpy handles versioning _and_ publishing (with workspace protocol resolution, topological ordering, etc.). +- **JS-specific features** — bumpy handles `workspace:` and `catalog:` protocol resolution, npm OIDC/provenance, staged publishing, and per-package publish commands. Release-please is language-agnostic but doesn't go as deep on npm-specific concerns. +- **Dependency propagation** — release-please doesn't model inter-package dependency relationships. In a JS monorepo where bumping `core` should cascade to `plugin`, you'd need to handle this yourself. + +**When to choose release-please:** You have a polyglot monorepo (Go + Python + Rust, etc.), your team already uses conventional commits, and you handle publishing separately. Its breadth of language support is unmatched. + +--- + +## release-it + +[release-it](https://github.com/release-it/release-it) is a flexible, interactive CLI tool for managing releases. It handles version bumping, git tagging, GitHub/GitLab releases, and npm publishing — typically run locally by a developer rather than fully automated in CI. + +**Where release-it shines:** + +- Interactive mode with confirmation prompts gives developers full control over each release step +- Works as a generic release tool beyond just npm — supports any project with git tags +- Plugin system for conventional changelogs, custom version sources, and monorepo support +- Pre-release version support (alpha, beta, rc) out of the box +- Lightweight and flexible — doesn't impose a specific workflow + +**Where bumpy differs:** + +- **PR-based workflow** — bumpy is designed around accumulating changes across multiple PRs via bump files, then releasing them together. release-it is typically a point-in-time "release what's on main now" tool. +- **Monorepo support** — release-it has monorepo recipes and plugins, but it's primarily designed for single-package repos. Bumpy's dependency propagation, per-package config, and workspace protocol handling are built for complex monorepos. +- **Changelog source** — release-it generates changelogs from git history. Bumpy uses human-written descriptions from bump files. +- **CI-first design** — bumpy is designed to run unattended in CI with its release PR workflow. release-it's strength is its interactive local flow (though it supports CI mode too). + +**When to choose release-it:** You prefer running releases locally with interactive confirmation, have a single-package repo, or need a lightweight tool that doesn't impose a PR-based workflow. + +--- + +## uppt + +[uppt](https://github.com/danielroe/uppt) is a composite GitHub Action by [Daniel Roe](https://github.com/danielroe) focused on secure npm publishing. It uses conventional commits to create release PRs, then packs and publishes via OIDC trusted publishing with npm staged releases. + +**Where uppt shines:** + +- Security-first design — OIDC trusted publishing with no stored tokens, immutable tarball artifacts, and staged publishing requiring manual npm approval +- Clean separation of concerns — four modular sub-actions (PR, release, pack, publish) with minimal permissions per step +- Fork protection guards against accidental releases from merged fork PRs +- Opinionated and simple — does one thing well with minimal configuration + +**Where bumpy differs:** + +- **Versioning model** — uppt uses conventional commits to determine versions. Bumpy uses explicit bump files, giving per-package control independent of commit style. +- **Monorepo support** — uppt is designed for single-package repos. Bumpy handles monorepos with dependency propagation, per-package config, and workspace protocol resolution. +- **Publishing flexibility** — uppt targets npm exclusively with staged publishing. Bumpy supports npm (with optional OIDC/provenance/staged), plus custom publish commands for other targets. +- **Scope** — uppt is a GitHub Action, so it's tied to GitHub Actions as a CI provider. Bumpy is a CLI that can run anywhere. + +**When to choose uppt:** You have a single npm package, want maximum supply-chain security with staged publishing, and prefer a GitHub Actions-native solution with minimal setup. + +--- + +## release-plan + +[release-plan](https://github.com/release-plan/release-plan) uses PR labels to drive versioning. Merged PRs are categorized by label (`breaking`, `enhancement`, `bug`, etc.), and the tool creates a release PR with computed version bumps and changelogs derived from PR titles. + +**Where release-plan shines:** + +- Minimal per-PR overhead — contributors just add a label, no files to create +- Changelog entries come from PR titles, which teams are already writing +- Simple mental model — one label, one PR, one version impact +- Zero local credentials needed — everything runs in CI +- Good support for pre-release workflows via `semverIncrementAs` and `publishTag` config + +**Where bumpy differs:** + +- **Multi-package PRs** — release-plan assigns one label per PR, so all packages in that PR get the same bump level. Bump files can specify different levels for different packages. +- **Changelog quality** — PR titles are often written for developer context ("Fix flaky test in auth module") rather than consumer context ("Fixed authentication timeout on slow connections"). Bump file descriptions are purpose-written for changelogs. +- **Monorepo depth** — release-plan supports monorepos but all packages share the same configuration. Bumpy offers per-package config, dependency propagation rules, and include/exclude controls. +- **Per-package configuration** — release-plan applies uniform config across all packages. Bumpy supports per-package publish commands, access levels, and propagation rules. +- **Publish targets** — release-plan publishes to npm. Bumpy supports npm plus custom publish commands for other targets. + +**When to choose release-plan:** You want the lowest possible friction per PR, your PRs typically affect one package each, and PR titles naturally serve as good changelog entries. Its simplicity is a genuine advantage for smaller projects. + +--- + +## Quick Reference + +| | Versioning source | Monorepo | Publish | Release PR | Commit convention required | +| -------------------- | ------------------------ | -------------- | -------------------- | ---------------- | -------------------------- | +| **bumpy** | Bump files (per PR) | First-class | npm + custom targets | Yes | No | +| **changesets** | Changeset files (per PR) | First-class | npm only | Yes | No | +| **semantic-release** | Commit messages | Via plugins | Via plugins | No (immediate) | Yes | +| **release-please** | Commit messages | Yes (manifest) | No (external) | Yes | Yes | +| **release-it** | Interactive / plugins | Via plugins | npm + git platforms | No (interactive) | Optional | +| **uppt** | Commit messages | No | npm (staged) | Yes | Yes | +| **release-plan** | PR labels | Yes | npm | Yes | No | diff --git a/docs/configuration.md b/docs/configuration.md index 87b6977..46cc147 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -18,7 +18,6 @@ Bumpy is configured via `.bumpy/_config.json`, created by `bumpy init`. Per-pack | `privatePackages` | `{ version, tag }` | `{ version: false, tag: false }` | Whether to version and/or create git tags for private packages | | `updateInternalDependencies` | `"patch" \| "minor" \| "out-of-range"` | `"out-of-range"` | When to update internal dependency version ranges | | `dependencyBumpRules` | `object` | see below | Controls how bumps propagate through dependency types | -| `aggregateRelease` | `boolean \| { enabled, title }` | `false` | Create a single GitHub release instead of one per package | | `versionCommitMessage` | `string` | — | Customize the version commit message (see below) | | `changedFilePatterns` | `string[]` | `["**"]` | Glob patterns to filter which changed files count toward marking a package as changed | | `publish` | `object` | see below | Publishing pipeline config | @@ -235,7 +234,6 @@ See the [Changelog Formatters](./changelog-formatters.md) docs for full details "provenance": true, "npmStaged": true }, - "aggregateRelease": true, "packages": { "@myorg/vscode-extension": { "publishCommand": "vsce publish", diff --git a/docs/differences-from-changesets.md b/docs/differences-from-changesets.md index 95d959b..0f96ecf 100644 --- a/docs/differences-from-changesets.md +++ b/docs/differences-from-changesets.md @@ -98,15 +98,6 @@ Bumpy defaults to `"access": "public"` since most open-source packages are publi - [changesets#1160](https://github.com/changesets/changesets/issues/1160) — filtered publish (34 thumbs-up) -### Aggregated GitHub releases - -`aggregateRelease: true` in config creates a single consolidated GitHub release instead of one per package. - -- [changesets#264](https://github.com/changesets/changesets/issues/264) — aggregated changelog (34 thumbs-up) -- [changesets#683](https://github.com/changesets/changesets/issues/683) — single changelog for fixed groups (16 thumbs-up) -- [changesets#1059](https://github.com/changesets/changesets/issues/1059) — aggregated GitHub releases (21 thumbs-up) -- [changesets#885](https://github.com/changesets/changesets/issues/885) — GitHub releases from CLI publish (19 thumbs-up) - ### Lockfile update after version `bumpy version` automatically runs `pnpm install --lockfile-only` / `bun install` / etc. to keep the lockfile in sync with bumped versions. diff --git a/packages/bumpy/config-schema.json b/packages/bumpy/config-schema.json index a4359e7..b4fc2a3 100644 --- a/packages/bumpy/config-schema.json +++ b/packages/bumpy/config-schema.json @@ -155,28 +155,6 @@ }, "additionalProperties": false }, - "aggregateRelease": { - "description": "GitHub release creation (requires gh CLI). false = individual release per package, true = single aggregated release, or an object with enabled and optional title (supports {{date}}).", - "default": false, - "oneOf": [ - { "type": "boolean" }, - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether to create an aggregated release" - }, - "title": { - "type": "string", - "description": "Custom title for the aggregated release (supports {{date}})" - } - }, - "required": ["enabled"], - "additionalProperties": false - } - ] - }, "gitUser": { "type": "object", "description": "Git identity used for CI commits", diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index c2cf9c7..d438ed7 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -266,9 +266,11 @@ export async function ciPlanCommand(rootDir: string): Promise { let output: PlanOutput; - if (bumpFiles.length > 0) { - // Bump files exist → version-pr mode - const plan = assembleReleasePlan(bumpFiles, packages, depGraph, config); + // Assemble plan from bump files (if any) + const plan = bumpFiles.length > 0 ? assembleReleasePlan(bumpFiles, packages, depGraph, config) : null; + + if (plan && plan.releases.length > 0) { + // Bump files produce actual releases → version-pr mode output = { mode: 'version-pr', bumpFiles: plan.bumpFiles.map((bf) => ({ @@ -280,7 +282,7 @@ export async function ciPlanCommand(rootDir: string): Promise { packageNames: plan.releases.map((r) => r.name), }; } else { - // No bump files → check for unpublished packages + // No releases from bump files (none-only or no bump files) → check for unpublished packages const { findUnpublishedPackages } = await import('./publish.ts'); const unpublished = await findUnpublishedPackages(packages, config); @@ -410,7 +412,12 @@ export async function ciReleaseCommand(rootDir: string, opts: ReleaseOptions): P const plan = assembleReleasePlan(bumpFiles, packages, depGraph, config); if (plan.releases.length === 0) { - log.info('Bump files found but no packages would be released.'); + // None-only bump files — ignore them for mode decisions and fall through to publish check. + // They'll be cleaned up when the next real version PR runs applyReleasePlan. + log.info('Bump files found but no packages would be released — checking for unpublished packages...'); + const recoveredBumpFiles = recoverDeletedBumpFiles(rootDir); + const { publishCommand } = await import('./publish.ts'); + await publishCommand(rootDir, { tag: opts.tag, recoveredBumpFiles }); return; } diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index 3b214cd..fdf98a4 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -4,10 +4,27 @@ import { discoverWorkspace } from '../core/workspace.ts'; import { DependencyGraph } from '../core/dep-graph.ts'; import { pushWithTags, hasUncommittedChanges } from '../core/git.ts'; import { publishPackages } from '../core/publish-pipeline.ts'; -import { createIndividualReleases, createAggregateRelease } from '../core/github-release.ts'; +import { + createIndividualReleases, + findReleaseByTag, + createDraftRelease, + updateReleaseBody, + updateReleaseBodyStatus, + finalizeRelease, + finalizeSupersededDrafts, + composeReleaseBody, + buildPublishUrl, + isGhAvailable, + getHeadSha, + generateReleaseBody, + buildReleaseBody, + type ReleaseMetadata, + type PublishTargetState, +} from '../core/github-release.ts'; import { loadFormatter } from '../core/changelog.ts'; import { detectWorkspaces } from '../utils/package-manager.ts'; import { CI_PLAN_CACHE_PATH } from './ci.ts'; +import { tryRunArgs } from '../utils/shell.ts'; import type { BumpyConfig, PackageConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; interface PublishCommandOptions { @@ -18,6 +35,8 @@ interface PublishCommandOptions { filter?: string; /** Recovered bump files from a version commit — used for GitHub release body generation */ recoveredBumpFiles?: import('../types.ts').BumpFile[]; + /** Package names to exclude from publishing (e.g., packages with pending non-none bumps) */ + excludePackages?: Set; } /** @@ -39,6 +58,17 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption // otherwise query the registry let toPublish = await findUnpublishedWithCache(rootDir, packages, config); + // Exclude packages with pending non-none bumps (they'll be superseded by the next version PR) + if (opts.excludePackages && opts.excludePackages.size > 0) { + const excluded = toPublish.filter((r) => opts.excludePackages!.has(r.name)); + if (excluded.length > 0) { + for (const r of excluded) { + log.dim(` Skipping ${r.name}@${r.newVersion} — pending bump will supersede this version`); + } + toPublish = toPublish.filter((r) => !opts.excludePackages!.has(r.name)); + } + } + // Apply filter if (opts.filter) { const { matchGlob } = await import('../core/config.ts'); @@ -78,6 +108,135 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption } console.log(); + // Load the changelog formatter for release note generation + const formatter = config.changelog !== false ? await loadFormatter(config.changelog, rootDir) : undefined; + const ghAvailable = isGhAvailable(); + + // Determine publish targets for each package + const publishTargetsByPkg = new Map(); + for (const release of toPublish) { + const pkg = packages.get(release.name)!; + const pkgConfig = pkg.bumpy || {}; + const targets: string[] = []; + if (pkgConfig.publishCommand) { + targets.push('custom'); + } else if (!pkgConfig.skipNpmPublish) { + targets.push('npm'); + } + publishTargetsByPkg.set(release.name, targets); + } + + // For each package, set up draft releases (if gh is available and not dry run) + const releaseMetadataByPkg = new Map< + string, + { tag: string; metadata: ReleaseMetadata; existingBody: string | null } + >(); + + if (ghAvailable && !opts.dryRun) { + for (const release of toPublish) { + const tag = `${release.name}@${release.newVersion}`; + const targets = publishTargetsByPkg.get(release.name) || []; + if (targets.length === 0) continue; + + const existing = await findReleaseByTag(tag, rootDir); + + if (existing && existing.metadata) { + // Existing draft/release with metadata — use it for retry logic + log.dim(` Found existing release for ${tag} (${existing.isDraft ? 'draft' : 'published'})`); + releaseMetadataByPkg.set(release.name, { + tag, + metadata: existing.metadata, + existingBody: existing.body, + }); + } else if (existing && !existing.metadata) { + // Existing release without bumpy metadata — leave it alone (user-created or old-style) + log.dim(` Found existing release for ${tag} without bumpy metadata — skipping draft management`); + } else { + // No existing release — finalize any stale drafts for older versions, then create a new draft + await finalizeSupersededDrafts(release.name, release.newVersion, rootDir); + + const changelogContent = formatter + ? await generateReleaseBody(release, releasePlan.bumpFiles, formatter) + : buildReleaseBody(release, releasePlan.bumpFiles); + + const initialTargets: Record = {}; + for (const t of targets) { + initialTargets[t] = { status: 'pending' }; + } + const metadata: ReleaseMetadata = { + version: release.newVersion, + targets: initialTargets, + }; + const body = composeReleaseBody(changelogContent, metadata); + const title = `${release.name} v${release.newVersion}`; + const headSha = getHeadSha(rootDir); + + try { + await createDraftRelease(tag, title, body, rootDir, headSha || undefined); + log.dim(` Created draft release: ${title}`); + releaseMetadataByPkg.set(release.name, { tag, metadata, existingBody: body }); + } catch (err) { + log.warn(` Failed to create draft release for ${tag}: ${err instanceof Error ? err.message : err}`); + } + } + } + + // Handle tag movement: if no targets succeeded yet, move tag to HEAD + for (const release of toPublish) { + const info = releaseMetadataByPkg.get(release.name); + if (!info) continue; + + const anySucceeded = Object.values(info.metadata.targets).some((t) => t.status === 'success'); + if (!anySucceeded) { + // Safe to move tag to HEAD + const tag = info.tag; + const headSha = getHeadSha(rootDir); + const tagSha = tryRunArgs(['git', 'rev-parse', tag], { cwd: rootDir }); + if (headSha && tagSha && headSha !== tagSha) { + // Count commits between tag and HEAD + const count = tryRunArgs(['git', 'rev-list', '--count', `${tag}..HEAD`], { cwd: rootDir }); + log.dim(` Moving version tag ${tag} to HEAD (includes ${count} commit(s) since versioning)`); + tryRunArgs(['git', 'tag', '-f', tag], { cwd: rootDir }); + } + } else { + // Tag stays — log divergence if any + const tag = info.tag; + const headSha = getHeadSha(rootDir); + const tagSha = tryRunArgs(['git', 'rev-parse', tag], { cwd: rootDir }); + if (headSha && tagSha && headSha !== tagSha) { + const count = tryRunArgs(['git', 'rev-list', '--count', `${tag}..HEAD`], { cwd: rootDir }); + log.warn( + ` HEAD is ${count} commit(s) ahead of version tag ${tag} — some targets already published from tagged commit`, + ); + } + } + } + } + + // Filter out packages where all targets already succeeded (from previous runs) + const alreadyPublished: string[] = []; + for (const release of toPublish) { + const info = releaseMetadataByPkg.get(release.name); + if (!info) continue; + const targets = publishTargetsByPkg.get(release.name) || []; + const allDone = targets.every((t) => info.metadata.targets[t]?.status === 'success'); + if (allDone) { + alreadyPublished.push(release.name); + } + } + if (alreadyPublished.length > 0) { + for (const name of alreadyPublished) { + log.dim(` Skipping ${name} — all targets already published (per draft release metadata)`); + } + toPublish = toPublish.filter((r) => !alreadyPublished.includes(r.name)); + releasePlan.releases = toPublish; + } + + if (toPublish.length === 0) { + log.info('All packages already published successfully.'); + return; + } + const result = await publishPackages( releasePlan, packages, @@ -99,6 +258,59 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption if (result.skipped.length > 0) { log.dim(`Skipped ${result.skipped.length}: ${result.skipped.map((s) => s.name).join(', ')}`); } + + // Update draft release metadata with results + if (ghAvailable && !opts.dryRun) { + for (const release of releasePlan.releases) { + const info = releaseMetadataByPkg.get(release.name); + if (!info) continue; + + const targets = publishTargetsByPkg.get(release.name) || []; + const published = result.published.find((p) => p.name === release.name); + const failed = result.failed.find((f) => f.name === release.name); + + let changed = false; + for (const targetName of targets) { + // Skip already-succeeded targets + if (info.metadata.targets[targetName]?.status === 'success') continue; + + if (published) { + info.metadata.targets[targetName] = { + status: 'success', + publishedAt: new Date().toISOString(), + url: buildPublishUrl(release.name, release.newVersion, targetName), + }; + changed = true; + } else if (failed) { + info.metadata.targets[targetName] = { + status: 'failed', + error: failed.error, + lastAttempt: new Date().toISOString(), + }; + changed = true; + } + } + + if (changed) { + try { + const updatedBody = info.existingBody + ? updateReleaseBodyStatus(info.existingBody, info.metadata) + : composeReleaseBody('', info.metadata); + await updateReleaseBody(info.tag, updatedBody, rootDir); + + // Finalize if all targets succeeded + const allSucceeded = Object.values(info.metadata.targets).every((t) => t.status === 'success'); + if (allSucceeded) { + await finalizeRelease(info.tag, rootDir); + log.dim(` Finalized release: ${info.tag}`); + } + } catch (err) { + log.warn(` Failed to update release for ${info.tag}: ${err instanceof Error ? err.message : err}`); + } + } + } + } + if (result.failed.length > 0) { log.error(`Failed ${result.failed.length}: ${result.failed.map((f) => `${f.name} (${f.error})`).join(', ')}`); process.exit(1); @@ -115,28 +327,13 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption } } - // GitHub releases - if (result.published.length > 0) { + // Fallback: if gh isn't available, we can't use draft releases — use legacy individual releases + if (!ghAvailable && result.published.length > 0) { const publishedReleases = releasePlan.releases.filter((r) => result.published.some((p) => p.name === r.name)); - const aggConfig = config.aggregateRelease; - const isAggregate = aggConfig === true || (typeof aggConfig === 'object' && aggConfig.enabled); - const aggTitle = typeof aggConfig === 'object' ? aggConfig.title : undefined; - - // Load the changelog formatter so GitHub release bodies match CHANGELOG.md - const formatter = config.changelog !== false ? await loadFormatter(config.changelog, rootDir) : undefined; - - if (isAggregate) { - await createAggregateRelease(publishedReleases, releasePlan.bumpFiles, rootDir, { - dryRun: opts.dryRun, - title: aggTitle, - formatter, - }); - } else { - await createIndividualReleases(publishedReleases, releasePlan.bumpFiles, rootDir, { - dryRun: opts.dryRun, - formatter, - }); - } + await createIndividualReleases(publishedReleases, releasePlan.bumpFiles, rootDir, { + dryRun: opts.dryRun, + formatter, + }); } } diff --git a/packages/bumpy/src/core/github-release.ts b/packages/bumpy/src/core/github-release.ts index 99b0b34..6f3dfbd 100644 --- a/packages/bumpy/src/core/github-release.ts +++ b/packages/bumpy/src/core/github-release.ts @@ -1,12 +1,11 @@ import { tryRunArgs, runArgsAsync } from '../utils/shell.ts'; import { log } from '../utils/logger.ts'; -import { listTags } from './git.ts'; import { generateChangelogEntry } from './changelog.ts'; import type { ChangelogFormatter } from './changelog.ts'; import type { PlannedRelease, BumpFile } from '../types.ts'; /** Get the current HEAD commit SHA */ -function getHeadSha(rootDir: string): string | null { +export function getHeadSha(rootDir: string): string | null { return tryRunArgs(['git', 'rev-parse', 'HEAD'], { cwd: rootDir }); } @@ -83,50 +82,8 @@ export async function createIndividualReleases( } } -/** Create a single aggregated GitHub release for all published packages */ -export async function createAggregateRelease( - releases: PlannedRelease[], - bumpFiles: BumpFile[], - rootDir: string, - opts: GitHubReleaseOptions = {}, -): Promise { - if (!isGhAvailable()) { - log.dim(' gh CLI not found — skipping GitHub release'); - return; - } - - if (releases.length === 0) return; - - const date = new Date().toISOString().split('T')[0]; - const existing = listTags(`release-${date}*`, { cwd: rootDir }); - const { tag, title } = resolveAggregateTagAndTitle(date!, existing, opts.title); - const body = opts.formatter - ? await generateAggregateBody(releases, bumpFiles, opts.formatter) - : buildAggregateBody(releases, bumpFiles); - - if (opts.dryRun) { - log.dim(` Would create aggregate GitHub release: ${title}`); - log.dim(` Tag: ${tag}`); - return; - } - - try { - // Create the tag if it doesn't exist - tryRunArgs(['git', 'tag', tag], { cwd: rootDir }); - - // Use --target so gh can create the tag on the remote if it wasn't pushed yet - const headSha = getHeadSha(rootDir); - const args = ['gh', 'release', 'create', tag, '--title', title, '--notes', body]; - if (headSha) args.push('--target', headSha); - await withReleaseToken(() => runArgsAsync(args, { cwd: rootDir })); - log.success(`Created aggregate GitHub release: ${title}`); - } catch (err) { - log.warn(`Failed to create aggregate GitHub release: ${err instanceof Error ? err.message : err}`); - } -} - /** Generate a release body for a single package using the changelog formatter */ -async function generateReleaseBody( +export async function generateReleaseBody( release: PlannedRelease, bumpFiles: BumpFile[], formatter: ChangelogFormatter, @@ -136,48 +93,6 @@ async function generateReleaseBody( return stripVersionHeading(entry).trim() || 'No changelog entries.'; } -/** Generate an aggregate release body using the changelog formatter */ -async function generateAggregateBody( - releases: PlannedRelease[], - bumpFiles: BumpFile[], - formatter: ChangelogFormatter, -): Promise { - const lines: string[] = []; - - // Group by bump type - const groups: [string, PlannedRelease[]][] = [ - ['Major Changes', releases.filter((r) => r.type === 'major')], - ['Minor Changes', releases.filter((r) => r.type === 'minor')], - ['Patch Changes', releases.filter((r) => r.type === 'patch')], - ]; - - for (const [heading, group] of groups) { - if (group.length === 0) continue; - lines.push(`## ${heading}\n`); - - for (const release of group) { - lines.push(`### ${release.name} v${release.newVersion}\n`); - const entry = await generateChangelogEntry(release, bumpFiles, formatter, undefined, 'github-release'); - const body = stripVersionHeading(entry).trim(); - if (body) { - lines.push(body); - } else if (release.isDependencyBump) { - const sourceList = release.bumpSources.map((s) => `\`${s.name}\` v${s.newVersion}`).join(', '); - lines.push(sourceList ? `- Updated dependency ${sourceList}` : '- Updated dependencies'); - } else if (release.isGroupBump) { - const sourceList = release.bumpSources.map((s) => `\`${s.name}\` v${s.newVersion}`).join(', '); - lines.push(sourceList ? `- Version bump from group with ${sourceList}` : '- Version bump from group'); - } else if (release.isCascadeBump) { - const sourceList = release.bumpSources.map((s) => `\`${s.name}\` v${s.newVersion}`).join(', '); - lines.push(sourceList ? `- Version bump from ${sourceList}` : '- Version bump via cascade rule'); - } - lines.push(''); - } - } - - return lines.join('\n').trim() || 'No changelog entries.'; -} - /** Strip the leading ## version heading and date sub-heading from a changelog entry */ function stripVersionHeading(entry: string): string { return entry @@ -186,7 +101,7 @@ function stripVersionHeading(entry: string): string { .replace(/^_.+_\n/, ''); // remove _date_ line } -function buildReleaseBody(release: PlannedRelease, bumpFiles: BumpFile[]): string { +export function buildReleaseBody(release: PlannedRelease, bumpFiles: BumpFile[]): string { const lines: string[] = []; const relevant = bumpFiles.filter((bf) => release.bumpFiles.includes(bf.id)); @@ -212,60 +127,261 @@ function buildReleaseBody(release: PlannedRelease, bumpFiles: BumpFile[]): strin return lines.join('\n') || 'No changelog entries.'; } -function buildAggregateBody(releases: PlannedRelease[], bumpFiles: BumpFile[]): string { - const lines: string[] = []; +export function isGhAvailable(): boolean { + return tryRunArgs(['gh', '--version']) !== null; +} - // Group by bump type - const groups: [string, PlannedRelease[]][] = [ - ['Major Changes', releases.filter((r) => r.type === 'major')], - ['Minor Changes', releases.filter((r) => r.type === 'minor')], - ['Patch Changes', releases.filter((r) => r.type === 'patch')], - ]; - - for (const [heading, group] of groups) { - if (group.length === 0) continue; - lines.push(`## ${heading}\n`); - - for (const release of group) { - lines.push(`### ${release.name} v${release.newVersion}\n`); - const relevant = bumpFiles.filter((bf) => release.bumpFiles.includes(bf.id)); - if (relevant.length > 0) { - for (const bf of relevant) { - if (bf.summary) { - lines.push(`- ${bf.summary.split('\n')[0]}`); - } - } - } else { - const sourceList = release.bumpSources.map((s) => `\`${s.name}\` v${s.newVersion}`).join(', '); - if (release.isDependencyBump) { - lines.push(sourceList ? `- Updated dependency ${sourceList}` : '- Updated dependencies'); - } else if (release.isGroupBump) { - lines.push(sourceList ? `- Version bump from group with ${sourceList}` : '- Version bump from group'); - } else if (release.isCascadeBump) { - lines.push(sourceList ? `- Version bump from ${sourceList}` : '- Version bump via cascade rule'); - } - } - lines.push(''); +// ---- Draft release / publish tracking system ---- + +const METADATA_START = ''; + +export type PublishTargetStatus = 'pending' | 'success' | 'failed' | 'skipped'; + +export interface PublishTargetState { + status: PublishTargetStatus; + publishedAt?: string; + error?: string; + lastAttempt?: string; + reason?: string; + supersededBy?: string; + url?: string; +} + +export interface ReleaseMetadata { + version: string; + targets: Record; +} + +export interface DraftReleaseInfo { + tag: string; + title: string; + body: string; + isDraft: boolean; + metadata: ReleaseMetadata | null; +} + +/** Parse bumpy metadata from a release body */ +export function parseReleaseMetadata(body: string): ReleaseMetadata | null { + const startIdx = body.indexOf(METADATA_START); + const endIdx = body.indexOf(METADATA_END); + if (startIdx === -1 || endIdx === -1) return null; + + const jsonStr = body.slice(startIdx + METADATA_START.length, endIdx).trim(); + try { + return JSON.parse(jsonStr); + } catch { + return null; + } +} + +/** Serialize metadata into an HTML comment */ +function serializeMetadata(metadata: ReleaseMetadata): string { + return `${METADATA_START}\n${JSON.stringify(metadata, null, 2)}\n${METADATA_END}`; +} + +/** Build the "Published to" section from target states */ +export function formatPublishedToSection(targets: Record): string { + const lines: string[] = ['#### Published to']; + for (const [name, state] of Object.entries(targets)) { + switch (state.status) { + case 'success': + lines.push(state.url ? `- ✅ [${name}](${state.url})` : `- ✅ ${name}`); + break; + case 'failed': + lines.push(`- ❌ ${name} — will retry on next CI run`); + break; + case 'skipped': + lines.push( + state.supersededBy + ? `- ⏭️ ${name} — skipped (superseded by ${state.supersededBy})` + : `- ⏭️ ${name} — skipped`, + ); + break; + case 'pending': + lines.push(`- ⏳ ${name}`); + break; } } + return lines.join('\n'); +} - return lines.join('\n').trim() || 'No changelog entries.'; +/** Build a URL for a published package on a registry */ +export function buildPublishUrl( + name: string, + version: string, + targetType: string, + _registry?: string, +): string | undefined { + switch (targetType) { + case 'npm': + 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]; + return parts.length === 2 + ? `https://jsr.io/@${parts[0]}/${parts[1]}@${version}` + : `https://jsr.io/${name}@${version}`; + } + default: + return undefined; + } } -/** Compute the aggregate release tag and title, appending -n suffix if a tag for the same date already exists */ -export function resolveAggregateTagAndTitle( - date: string, - existingTags: string[], - titleTemplate?: string, -): { tag: string; title: string } { - const baseTag = `release-${date}`; - const suffix = existingTags.length === 0 ? '' : `-${existingTags.length + 1}`; - const tag = `${baseTag}${suffix}`; - const template = titleTemplate || 'Release {{date}}'; - const title = template.replace('{{date}}', `${date}${suffix}`); - return { tag, title }; +/** + * Compose a full release body from changelog content + publish status + metadata. + * Preserves existing changelog content when updating (only replaces the status/metadata sections). + */ +export function composeReleaseBody(changelogContent: string, metadata: ReleaseMetadata): string { + const publishSection = formatPublishedToSection(metadata.targets); + const metadataComment = serializeMetadata(metadata); + return `${changelogContent}\n\n${publishSection}\n\n${metadataComment}`; } -function isGhAvailable(): boolean { - return tryRunArgs(['gh', '--version']) !== null; +/** + * Update just the status/metadata sections of an existing release body, + * preserving the changelog content above. + */ +export function updateReleaseBodyStatus(existingBody: string, metadata: ReleaseMetadata): string { + // Find where the "Published to" section starts + const publishIdx = existingBody.indexOf('#### Published to'); + const metaIdx = existingBody.indexOf(METADATA_START); + + // Determine where changelog content ends + let changelogContent: string; + if (publishIdx !== -1) { + changelogContent = existingBody.slice(0, publishIdx).trimEnd(); + } else if (metaIdx !== -1) { + changelogContent = existingBody.slice(0, metaIdx).trimEnd(); + } else { + changelogContent = existingBody.trimEnd(); + } + + return composeReleaseBody(changelogContent, metadata); +} + +/** Look up an existing GitHub release (draft or published) by tag */ +export async function findReleaseByTag(tag: string, rootDir: string): Promise { + if (!isGhAvailable()) return null; + + try { + const json = await runArgsAsync(['gh', 'release', 'view', tag, '--json', 'tagName,name,body,isDraft'], { + cwd: rootDir, + }); + const data = JSON.parse(json); + return { + tag: data.tagName, + title: data.name, + body: data.body, + isDraft: data.isDraft, + metadata: parseReleaseMetadata(data.body), + }; + } catch { + return null; + } +} + +/** Create a draft GitHub release */ +export async function createDraftRelease( + tag: string, + title: string, + body: string, + rootDir: string, + targetSha?: string, +): Promise { + const args = ['gh', 'release', 'create', tag, '--title', title, '--notes', body, '--draft']; + if (targetSha) args.push('--target', targetSha); + await runArgsAsync(args, { cwd: rootDir }); +} + +/** Update an existing GitHub release's body */ +export async function updateReleaseBody(tag: string, body: string, rootDir: string): Promise { + await runArgsAsync(['gh', 'release', 'edit', tag, '--notes', body], { cwd: rootDir }); +} + +/** Finalize a draft release (remove draft status) */ +export async function finalizeRelease(tag: string, rootDir: string): Promise { + await runArgsAsync(['gh', 'release', 'edit', tag, '--draft=false'], { cwd: rootDir }); +} + +/** Delete a GitHub release */ +export async function deleteRelease(tag: string, rootDir: string): Promise { + await runArgsAsync(['gh', 'release', 'delete', tag, '--yes'], { cwd: rootDir }); +} + +/** Find draft releases for a package (by name prefix) that are older than the current version */ +export async function findStaleDraftReleases( + packageName: string, + currentVersion: string, + rootDir: string, +): Promise> { + if (!isGhAvailable()) return []; + + const currentTag = `${packageName}@${currentVersion}`; + try { + const json = await runArgsAsync(['gh', 'release', 'list', '--json', 'tagName,isDraft,name', '--limit', '20'], { + cwd: rootDir, + }); + const releases: Array<{ tagName: string; isDraft: boolean; name: string }> = JSON.parse(json); + + const stale: Array<{ tag: string; body: string; metadata: ReleaseMetadata | null }> = []; + for (const r of releases) { + // Only look at drafts for the same package with a different version + if (!r.isDraft) continue; + if (!r.tagName.startsWith(`${packageName}@`)) continue; + if (r.tagName === currentTag) continue; + + // Fetch full body to check metadata + const info = await findReleaseByTag(r.tagName, rootDir); + if (info) { + stale.push({ tag: r.tagName, body: info.body, metadata: info.metadata }); + } + } + return stale; + } catch { + return []; + } +} + +/** + * Finalize stale draft releases as superseded. + * Updates their metadata targets to "skipped" and marks them as non-draft. + */ +export async function finalizeSupersededDrafts( + packageName: string, + newVersion: string, + rootDir: string, +): Promise { + const staleDrafts = await findStaleDraftReleases(packageName, newVersion, rootDir); + + for (const draft of staleDrafts) { + log.dim(` Finalizing draft release ${draft.tag} — superseded by ${newVersion}`); + + if (draft.metadata) { + // Update all non-success targets to "skipped" + for (const [targetName, state] of Object.entries(draft.metadata.targets)) { + if (state.status !== 'success') { + draft.metadata.targets[targetName] = { + status: 'skipped', + reason: 'superseded', + supersededBy: newVersion, + }; + } + } + const updatedBody = updateReleaseBodyStatus(draft.body, draft.metadata); + try { + await updateReleaseBody(draft.tag, updatedBody, rootDir); + await finalizeRelease(draft.tag, rootDir); + } catch (err) { + log.warn(` Failed to finalize superseded release ${draft.tag}: ${err instanceof Error ? err.message : err}`); + } + } else { + // No metadata — just finalize the draft as-is + try { + await finalizeRelease(draft.tag, rootDir); + } catch (err) { + log.warn(` Failed to finalize superseded release ${draft.tag}: ${err instanceof Error ? err.message : err}`); + } + } + } } diff --git a/packages/bumpy/src/types.ts b/packages/bumpy/src/types.ts index b5c509f..97d053a 100644 --- a/packages/bumpy/src/types.ts +++ b/packages/bumpy/src/types.ts @@ -130,13 +130,6 @@ export interface BumpyConfig { allowCustomCommands: boolean | string[]; packages: Record; publish: PublishConfig; - /** - * GitHub release creation (requires `gh` CLI). - * false = individual release per package (default) - * true = single aggregated release for all packages - * { enabled: true, title: "..." } = aggregate with custom title (supports {{date}}) - */ - aggregateRelease: boolean | { enabled: boolean; title?: string }; /** Git identity used for CI commits. Defaults to bumpy-bot. */ gitUser: { name: string; email: string }; /** Version PR settings */ @@ -192,7 +185,6 @@ export const DEFAULT_CONFIG: BumpyConfig = { allowCustomCommands: false, packages: {}, publish: { ...DEFAULT_PUBLISH_CONFIG }, - aggregateRelease: false, gitUser: { name: 'bumpy-bot', email: '276066384+bumpy-bot@users.noreply.github.com' }, versionPr: { title: '🐸 Versioned release', diff --git a/packages/bumpy/test/core/github-release.test.ts b/packages/bumpy/test/core/github-release.test.ts index 96a49b3..c2d928c 100644 --- a/packages/bumpy/test/core/github-release.test.ts +++ b/packages/bumpy/test/core/github-release.test.ts @@ -1,136 +1,11 @@ import { test, expect, describe, beforeEach, afterEach } from 'bun:test'; import { makeRelease, createTempGitRepo, cleanupTempDir } from '../helpers.ts'; import { installShellMock, uninstallShellMock, getCallsMatching, addMockRule } from '../helpers-shell-mock.ts'; -import { listTags, tagExists } from '../../src/core/git.ts'; -import { - resolveAggregateTagAndTitle, - createAggregateRelease, - createIndividualReleases, -} from '../../src/core/github-release.ts'; - -// ---- Pure unit tests for tag/title resolution ---- - -describe('resolveAggregateTagAndTitle', () => { - test('first release of the day gets no suffix', () => { - const result = resolveAggregateTagAndTitle('2026-04-14', []); - expect(result.tag).toBe('release-2026-04-14'); - expect(result.title).toBe('Release 2026-04-14'); - }); - - test('second release of the day gets -2 suffix', () => { - const result = resolveAggregateTagAndTitle('2026-04-14', ['release-2026-04-14']); - expect(result.tag).toBe('release-2026-04-14-2'); - expect(result.title).toBe('Release 2026-04-14-2'); - }); - - test('third release of the day gets -3 suffix', () => { - const existing = ['release-2026-04-14', 'release-2026-04-14-2']; - const result = resolveAggregateTagAndTitle('2026-04-14', existing); - expect(result.tag).toBe('release-2026-04-14-3'); - expect(result.title).toBe('Release 2026-04-14-3'); - }); - - test('custom title template gets date+suffix substituted', () => { - const result = resolveAggregateTagAndTitle('2026-04-14', ['release-2026-04-14'], 'Deploy {{date}}'); - expect(result.title).toBe('Deploy 2026-04-14-2'); - }); - - test('custom title with no suffix', () => { - const result = resolveAggregateTagAndTitle('2026-04-14', [], 'v{{date}}'); - expect(result.title).toBe('v2026-04-14'); - }); -}); +import { tagExists } from '../../src/core/git.ts'; +import { createIndividualReleases } from '../../src/core/github-release.ts'; // ---- Integration tests using real git repos + mocked gh CLI ---- -describe('createAggregateRelease', () => { - let tmpDir: string; - - beforeEach(async () => { - tmpDir = await createTempGitRepo(); - installShellMock(); - addMockRule({ match: /^gh release create/, response: '' }); - }); - - afterEach(async () => { - uninstallShellMock(); - await cleanupTempDir(tmpDir); - }); - - test('creates a date-based git tag', async () => { - const releases = [makeRelease('pkg-a', '1.0.0', { type: 'minor' })]; - - await createAggregateRelease(releases, [], tmpDir); - - const today = new Date().toISOString().split('T')[0]; - expect(tagExists(`release-${today}`, { cwd: tmpDir })).toBe(true); - }); - - test('calls gh release create with correct arguments', async () => { - const releases = [makeRelease('pkg-a', '1.0.0', { type: 'minor' })]; - - await createAggregateRelease(releases, [], tmpDir); - - const ghCalls = getCallsMatching('gh release create'); - expect(ghCalls).toHaveLength(1); - expect(ghCalls[0]!.command).toContain('release-'); - expect(ghCalls[0]!.command).toContain('--title'); - expect(ghCalls[0]!.command).toContain('--notes'); - }); - - test('second call on same day creates tag with -2 suffix', async () => { - const releases = [makeRelease('pkg-a', '1.0.0')]; - const today = new Date().toISOString().split('T')[0]; - - await createAggregateRelease(releases, [], tmpDir); - expect(tagExists(`release-${today}`, { cwd: tmpDir })).toBe(true); - - await createAggregateRelease(releases, [], tmpDir); - expect(tagExists(`release-${today}-2`, { cwd: tmpDir })).toBe(true); - - await createAggregateRelease(releases, [], tmpDir); - expect(tagExists(`release-${today}-3`, { cwd: tmpDir })).toBe(true); - - const tags = listTags(`release-${today}*`, { cwd: tmpDir }); - expect(tags).toHaveLength(3); - }); - - test('skips with empty releases array', async () => { - await createAggregateRelease([], [], tmpDir); - - const tags = listTags('release-*', { cwd: tmpDir }); - expect(tags).toHaveLength(0); - const ghCalls = getCallsMatching('gh release create'); - expect(ghCalls).toHaveLength(0); - }); - - test('handles gh failure gracefully', async () => { - // Override the default gh release create rule with an error - installShellMock(); - addMockRule({ match: /^gh release create/, error: 'auth required' }); - - const releases = [makeRelease('pkg-a', '1.0.0', { type: 'minor' })]; - - // Should not throw - await createAggregateRelease(releases, [], tmpDir); - - // Tag should still be created (git tag happens before gh release create) - const today = new Date().toISOString().split('T')[0]; - expect(tagExists(`release-${today}`, { cwd: tmpDir })).toBe(true); - }); - - test('skips entirely when gh is not available', async () => { - installShellMock({ interceptGh: false }); - addMockRule({ match: 'gh --version', error: 'not found' }); - - const releases = [makeRelease('pkg-a', '1.0.0')]; - await createAggregateRelease(releases, [], tmpDir); - - const tags = listTags('release-*', { cwd: tmpDir }); - expect(tags).toHaveLength(0); - }); -}); - describe('createIndividualReleases', () => { let tmpDir: string; diff --git a/packages/bumpy/test/core/publish-recovery.test.ts b/packages/bumpy/test/core/publish-recovery.test.ts new file mode 100644 index 0000000..cd7a7fa --- /dev/null +++ b/packages/bumpy/test/core/publish-recovery.test.ts @@ -0,0 +1,216 @@ +import { test, expect, describe } from 'bun:test'; +import { + parseReleaseMetadata, + formatPublishedToSection, + composeReleaseBody, + updateReleaseBodyStatus, + buildPublishUrl, + type ReleaseMetadata, +} from '../../src/core/github-release.ts'; + +describe('parseReleaseMetadata', () => { + test('parses valid metadata from release body', () => { + const body = `## What's Changed + +- Fixed a bug + +#### Published to +- ✅ npm + +`; + + const metadata = parseReleaseMetadata(body); + expect(metadata).not.toBeNull(); + expect(metadata!.version).toBe('1.9.2'); + expect(metadata!.targets['npm']!.status).toBe('success'); + expect(metadata!.targets['npm']!.publishedAt).toBe('2026-05-25T10:30:00Z'); + }); + + test('returns null for body without metadata', () => { + const body = `## What's Changed\n\n- Fixed a bug`; + expect(parseReleaseMetadata(body)).toBeNull(); + }); + + test('returns null for malformed metadata JSON', () => { + const body = ``; + expect(parseReleaseMetadata(body)).toBeNull(); + }); + + test('parses metadata with multiple targets', () => { + const body = ``; + + const metadata = parseReleaseMetadata(body); + expect(metadata!.targets['npm']!.status).toBe('success'); + expect(metadata!.targets['jsr']!.status).toBe('failed'); + expect(metadata!.targets['jsr']!.error).toBe('auth timeout'); + }); +}); + +describe('formatPublishedToSection', () => { + test('formats all-success targets with URLs', () => { + const targets = { + npm: { status: 'success' as const, url: 'https://www.npmjs.com/package/foo/v/1.0.0' }, + jsr: { status: 'success' as const, url: 'https://jsr.io/@scope/foo@1.0.0' }, + }; + const result = formatPublishedToSection(targets); + expect(result).toContain('#### Published to'); + expect(result).toContain('- ✅ [npm](https://www.npmjs.com/package/foo/v/1.0.0)'); + expect(result).toContain('- ✅ [jsr](https://jsr.io/@scope/foo@1.0.0)'); + }); + + test('formats success without URL', () => { + const targets = { custom: { status: 'success' as const } }; + const result = formatPublishedToSection(targets); + expect(result).toContain('- ✅ custom'); + }); + + test('formats failed targets', () => { + const targets = { npm: { status: 'failed' as const, error: 'timeout' } }; + const result = formatPublishedToSection(targets); + expect(result).toContain('- ❌ npm — will retry on next CI run'); + }); + + test('formats skipped/superseded targets', () => { + const targets = { jsr: { status: 'skipped' as const, supersededBy: '1.9.3' } }; + const result = formatPublishedToSection(targets); + expect(result).toContain('- ⏭️ jsr — skipped (superseded by 1.9.3)'); + }); + + test('formats pending targets', () => { + const targets = { npm: { status: 'pending' as const } }; + const result = formatPublishedToSection(targets); + expect(result).toContain('- ⏳ npm'); + }); +}); + +describe('composeReleaseBody', () => { + test('combines changelog content with status and metadata', () => { + const metadata: ReleaseMetadata = { + version: '1.0.0', + targets: { npm: { status: 'pending' } }, + }; + const body = composeReleaseBody('- Fixed a bug', metadata); + expect(body).toContain('- Fixed a bug'); + expect(body).toContain('#### Published to'); + expect(body).toContain('- ⏳ npm'); + expect(body).toContain(''); + }); +}); + +describe('updateReleaseBodyStatus', () => { + test('replaces status section while preserving changelog content', () => { + const existingBody = `## What's Changed + +- Fixed a bug +- Added feature + +#### Published to +- ⏳ npm + +`; + + const updatedMetadata: ReleaseMetadata = { + version: '1.0.0', + targets: { + npm: { + status: 'success', + publishedAt: '2026-05-25T10:00:00Z', + url: 'https://www.npmjs.com/package/foo/v/1.0.0', + }, + }, + }; + + const result = updateReleaseBodyStatus(existingBody, updatedMetadata); + // Changelog preserved + expect(result).toContain("## What's Changed"); + expect(result).toContain('- Fixed a bug'); + expect(result).toContain('- Added feature'); + // Status updated + expect(result).toContain('- ✅ [npm](https://www.npmjs.com/package/foo/v/1.0.0)'); + expect(result).not.toContain('⏳'); + // Metadata updated + expect(result).toContain('"status": "success"'); + }); + + test('handles body without existing status section', () => { + const existingBody = '- Fixed a bug'; + const metadata: ReleaseMetadata = { + version: '1.0.0', + targets: { npm: { status: 'success' } }, + }; + const result = updateReleaseBodyStatus(existingBody, metadata); + expect(result).toContain('- Fixed a bug'); + expect(result).toContain('#### Published to'); + expect(result).toContain('- ✅ npm'); + }); + + test('preserves manually edited content above status section', () => { + const existingBody = `## Custom Release Notes + +This release includes important security fixes. + +> Note: Please upgrade ASAP. + +#### Published to +- ❌ npm — will retry on next CI run + +`; + + const metadata: ReleaseMetadata = { + version: '1.0.0', + targets: { npm: { status: 'success', url: 'https://www.npmjs.com/package/foo/v/1.0.0' } }, + }; + const result = updateReleaseBodyStatus(existingBody, metadata); + expect(result).toContain('## Custom Release Notes'); + expect(result).toContain('This release includes important security fixes.'); + expect(result).toContain('> Note: Please upgrade ASAP.'); + expect(result).toContain('- ✅ [npm]'); + expect(result).not.toContain('❌'); + }); +}); + +describe('buildPublishUrl', () => { + test('builds npm URL', () => { + expect(buildPublishUrl('@varlock/bumpy', '1.9.2', 'npm')).toBe( + 'https://www.npmjs.com/package/@varlock/bumpy/v/1.9.2', + ); + }); + + test('builds npm URL for unscoped package', () => { + expect(buildPublishUrl('bumpy', '1.0.0', 'npm')).toBe('https://www.npmjs.com/package/bumpy/v/1.0.0'); + }); + + test('builds jsr URL for scoped package', () => { + expect(buildPublishUrl('@varlock/bumpy', '1.9.2', 'jsr')).toBe('https://jsr.io/@varlock/bumpy@1.9.2'); + }); + + test('returns undefined for custom target', () => { + expect(buildPublishUrl('pkg', '1.0.0', 'custom')).toBeUndefined(); + }); +});