From 7d00c91dbee3c0cd7ac8c840e9b53a12a04836b3 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 29 Apr 2026 22:57:46 -0700 Subject: [PATCH 1/4] Add `bumpy ci plan` command New CI command that reports what `ci release` would do without acting. Outputs JSON to stdout and sets GitHub Actions step outputs (mode, packages, json) for conditionally gating expensive build steps. - mode: "version-pr" | "publish" | "nothing" - Caches result so `ci release` skips duplicate registry lookups - Cache validated against workspace (names + versions must match) - Added concurrency groups to release workflow examples - Updated docs (cli.md, github-actions.md) and own workflow --- .github/workflows/release.yaml | 12 +- docs/cli.md | 39 +++++++ docs/github-actions.md | 83 ++++++++++++- packages/bumpy/src/cli.ts | 6 +- packages/bumpy/src/commands/ci.ts | 156 ++++++++++++++++++++++++- packages/bumpy/src/commands/publish.ts | 82 ++++++++++++- 6 files changed, 370 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 2db18e9..cc519e3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -3,6 +3,10 @@ on: push: branches: [main] +concurrency: + group: bumpy-release + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest @@ -28,7 +32,13 @@ jobs: - run: bun install # ------------------------------- - # 🐸 This is the important part - creates/updates release PR when PRs merge to main, publishes packages when release PR is merged + # 🐸 Plan first — detects mode and caches the result for ci release + - id: plan + run: bunx @varlock/bumpy ci plan + env: + GH_TOKEN: ${{ github.token }} + + # Creates/updates release PR when PRs merge to main, publishes packages when release PR is merged - run: bunx @varlock/bumpy ci release env: GH_TOKEN: ${{ github.token }} diff --git a/docs/cli.md b/docs/cli.md index 7f320ac..07bb080 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -178,6 +178,45 @@ bumpy ci check --no-fail Requires `GH_TOKEN` environment variable (automatically available in GitHub Actions). +## `bumpy ci plan` + +CI command that reports what `ci release` would do, without acting. Outputs JSON to stdout and sets GitHub Actions step outputs so you can conditionally run expensive steps (builds, etc.) only when needed. + +```bash +bumpy ci plan +``` + +**Output (JSON to stdout):** + +```jsonc +{ + "mode": "version-pr", // "version-pr" | "publish" | "nothing" + "bumpFiles": [...], // same shape as `bumpy status --json` + "releases": [...], + "packageNames": ["pkg-a", "pkg-b"] +} +``` + +**Modes:** + +| Mode | Meaning | `releases` contains | +| ------------ | ------------------------------------------------------ | -------------------------------- | +| `version-pr` | Bump files exist — would create/update the version PR | Planned releases from bump files | +| `publish` | No bump files — unpublished packages detected | Packages that would be published | +| `nothing` | No bump files, no unpublished packages — nothing to do | Empty | + +**GitHub Actions outputs** (set via `$GITHUB_OUTPUT`): + +| Output | Description | +| ---------- | ------------------------------------- | +| `mode` | `version-pr`, `publish`, or `nothing` | +| `packages` | Comma-separated package names | +| `json` | Full JSON output (for `fromJSON()`) | + +When `ci plan` runs before `ci release` in the same workflow, the plan is cached so `ci release` can skip duplicate registry lookups. The cache is validated against the workspace (package names and versions must match) and deleted after use. + +See [GitHub Actions setup](github-actions.md) for workflow examples. + ## `bumpy ci release` CI command for releases. Has two modes: diff --git a/docs/github-actions.md b/docs/github-actions.md index 582c723..25123bf 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -7,6 +7,7 @@ Bumpy handles CI automation with two commands — no separate GitHub Action or b | Command | Trigger | What it does | | ------------------ | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `bumpy ci check` | `pull_request` | Posts/updates a PR comment with the release plan. Warns about missing bump files. | +| `bumpy ci plan` | `push` to main | Reports what `ci release` would do (JSON + GitHub Actions outputs). Use to conditionally gate expensive steps. | | `bumpy ci release` | `push` to main | Creates/updates a "Version Packages" PR. When that PR is merged, publishes packages, creates git tags, and creates GitHub releases. | ## PR check workflow @@ -32,7 +33,7 @@ jobs: ## Release workflow -### Trusted publishing (OIDC — recommended) +### Trusted publishing (OIDC ��� recommended) No `NPM_TOKEN` secret needed. Requires npm >= 11.5.1. @@ -43,6 +44,10 @@ on: push: branches: [main] +concurrency: + group: bumpy-release + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest @@ -78,6 +83,10 @@ on: push: branches: [main] +concurrency: + group: bumpy-release + cancel-in-progress: false + jobs: release: runs-on: ubuntu-latest @@ -105,6 +114,78 @@ Instead of the two-step flow (version PR → merge → publish), you can version - run: bunx @varlock/bumpy ci release --auto-publish ``` +## Conditional builds with `ci plan` + +Publishing often requires expensive build steps that aren't needed when just updating the version PR. Use `bumpy ci plan` to detect what `ci release` would do and conditionally gate those steps. + +`ci plan` outputs JSON to stdout, sets GitHub Actions step outputs, and caches the result so that `ci release` can skip duplicate registry lookups in the same workflow run. + +| Output | Description | +| ---------- | ------------------------------------- | +| `mode` | `version-pr`, `publish`, or `nothing` | +| `packages` | Comma-separated package names | +| `json` | Full JSON output (for `fromJSON()`) | + +### Basic: skip builds unless publishing + +```yaml +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + id-token: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v6 + with: + node-version: lts/* + - run: bun install + + - id: plan + run: bunx @varlock/bumpy ci plan + env: + GH_TOKEN: ${{ github.token }} + + # Only run expensive build when we're about to publish + - if: steps.plan.outputs.mode == 'publish' + run: bun run build + + - run: bunx @varlock/bumpy ci release + env: + GH_TOKEN: ${{ github.token }} + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} +``` + +### Advanced: conditional steps per package + +```yaml +- id: plan + run: bunx @varlock/bumpy ci plan + env: + GH_TOKEN: ${{ github.token }} + +# Build only specific packages that are being released +- if: contains(steps.plan.outputs.packages, 'my-expensive-package') + run: bun run build --filter=my-expensive-package +``` + +## Concurrency + +Use a concurrency group on your release workflow to prevent overlapping publish runs. Without this, rapid merges to main could trigger multiple workflows that race to publish the same packages. + +```yaml +concurrency: + group: bumpy-release + cancel-in-progress: false # queue rather than cancel — don't skip releases +``` + +This is included in all the workflow examples above. + ## Token setup ### `GH_TOKEN` (required) diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index 2c16e8c..89cffc7 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -112,6 +112,9 @@ async function main() { strict: ciFlags.strict === true, noFail: ciFlags['no-fail'] === true, }); + } else if (subcommand === 'plan') { + const { ciPlanCommand } = await import('./commands/ci.ts'); + await ciPlanCommand(rootDir); } else if (subcommand === 'release') { const { ciReleaseCommand } = await import('./commands/ci.ts'); const mode = ciFlags['auto-publish'] === true ? ('auto-publish' as const) : ('version-pr' as const); @@ -124,7 +127,7 @@ async function main() { const { ciSetupCommand } = await import('./commands/ci-setup.ts'); await ciSetupCommand(rootDir); } else { - log.error(`Unknown ci subcommand: ${subcommand}. Use "ci check", "ci release", or "ci setup".`); + log.error(`Unknown ci subcommand: ${subcommand}. Use "ci check", "ci plan", "ci release", or "ci setup".`); process.exit(1); } break; @@ -202,6 +205,7 @@ function printHelp() { version [--commit] Apply bump files and bump versions publish Publish versioned packages ci check PR check — report pending releases, comment on PR + ci plan Report what ci release would do (JSON + GitHub Actions outputs) ci release Release — create version PR or auto-publish ci setup Set up a token for triggering CI on version PRs ai setup Install AI skill for creating bump files diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 7140cf9..dbf9ed0 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -10,8 +10,9 @@ import { runArgs, runArgsAsync, tryRunArgs } from '../utils/shell.ts'; import { randomName } from '../utils/names.ts'; import { detectPackageManager } from '../utils/package-manager.ts'; import { createHash } from 'node:crypto'; +import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { resolveCommitMessage } from '../core/commit-message.ts'; -import type { BumpyConfig, BumpFile, PackageManager, ReleasePlan, PlannedRelease } from '../types.ts'; +import type { BumpyConfig, BumpFile, PackageConfig, PackageManager, ReleasePlan, PlannedRelease } from '../types.ts'; // ---- PAT-scoped gh helpers ---- @@ -212,6 +213,159 @@ export async function ciCheckCommand(rootDir: string, opts: CheckOptions): Promi } } +// ---- ci plan ---- + +/** Path (relative to rootDir) where ci plan caches its output for ci release to reuse */ +export const CI_PLAN_CACHE_PATH = 'node_modules/.cache/bumpy/ci-plan.json'; + +export type CiPlanMode = 'version-pr' | 'publish' | 'nothing'; + +interface PlanRelease { + name: string; + type: string; + oldVersion: string; + newVersion: string; + dir?: string; + bumpFiles: string[]; + isDependencyBump: boolean; + isCascadeBump: boolean; + publishTargets: string[]; +} + +interface PlanOutput { + mode: CiPlanMode; + bumpFiles: Array<{ + id: string; + summary: string; + releases: Array<{ name: string; type: string }>; + }>; + releases: PlanRelease[]; + packageNames: string[]; +} + +/** + * CI plan: report what `ci release` would do, without acting. + * Outputs JSON to stdout and sets GitHub Actions outputs when detected. + */ +export async function ciPlanCommand(rootDir: string): Promise { + const config = await loadConfig(rootDir); + const { packages } = await discoverWorkspace(rootDir, config); + const depGraph = new DependencyGraph(packages); + const { bumpFiles, errors: parseErrors } = await readBumpFiles(rootDir); + + if (parseErrors.length > 0) { + for (const err of parseErrors) { + log.error(err); + } + throw new Error('Bump file parse errors must be fixed before planning.'); + } + + let output: PlanOutput; + + if (bumpFiles.length > 0) { + // Bump files exist → version-pr mode + const plan = assembleReleasePlan(bumpFiles, packages, depGraph, config); + output = { + mode: 'version-pr', + bumpFiles: plan.bumpFiles.map((bf) => ({ + id: bf.id, + summary: bf.summary, + releases: bf.releases.map((r) => ({ name: r.name, type: r.type })), + })), + releases: plan.releases.map((r) => formatPlanRelease(r, packages, config)), + packageNames: plan.releases.map((r) => r.name), + }; + } else { + // No bump files → check for unpublished packages + const { findUnpublishedPackages } = await import('./publish.ts'); + const unpublished = await findUnpublishedPackages(packages, config); + + if (unpublished.length > 0) { + output = { + mode: 'publish', + bumpFiles: [], + releases: unpublished.map((r) => formatPlanRelease(r, packages, config)), + packageNames: unpublished.map((r) => r.name), + }; + } else { + output = { + mode: 'nothing', + bumpFiles: [], + releases: [], + packageNames: [], + }; + } + } + + // JSON to stdout + const json = JSON.stringify(output, null, 2); + console.log(json); + + // Cache for ci release to reuse (avoids duplicate registry lookups) + const cachePath = `${rootDir}/${CI_PLAN_CACHE_PATH}`; + const cacheDir = cachePath.slice(0, cachePath.lastIndexOf('/')); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cachePath, json, 'utf-8'); + + // Set GitHub Actions outputs + writeGitHubOutput('mode', output.mode); + writeGitHubOutput('packages', output.packageNames.join(',')); + writeGitHubOutput('json', JSON.stringify(output)); +} + +function formatPlanRelease( + r: { + name: string; + type: string; + oldVersion: string; + newVersion: string; + bumpFiles: string[]; + isDependencyBump: boolean; + isCascadeBump: boolean; + }, + packages: Map, + config: BumpyConfig, +): PlanRelease { + const pkg = packages.get(r.name); + return { + name: r.name, + type: r.type, + oldVersion: r.oldVersion, + newVersion: r.newVersion, + dir: pkg?.relativeDir, + bumpFiles: r.bumpFiles, + isDependencyBump: r.isDependencyBump, + isCascadeBump: r.isCascadeBump, + publishTargets: getPublishTargets(pkg, config), + }; +} + +function getPublishTargets( + pkg: { private: boolean; bumpy?: PackageConfig } | undefined, + _config: BumpyConfig, +): string[] { + if (!pkg) return []; + const pkgConfig = pkg.bumpy || {}; + if (pkg.private && !pkgConfig.publishCommand) return []; + const targets: string[] = []; + if (pkgConfig.publishCommand) { + targets.push('custom'); + } + if (!pkgConfig.publishCommand && !pkgConfig.skipNpmPublish) { + targets.push('npm'); + } + return targets; +} + +/** Write a key=value pair to $GITHUB_OUTPUT if available */ +function writeGitHubOutput(key: string, value: string): void { + const outputFile = process.env.GITHUB_OUTPUT; + if (!outputFile) return; + // Use delimiter protocol for multiline values + const delimiter = `ghadelimiter_${Date.now()}`; + appendFileSync(outputFile, `${key}<<${delimiter}\n${value}\n${delimiter}\n`); +} + // ---- ci release ---- interface ReleaseOptions { diff --git a/packages/bumpy/src/commands/publish.ts b/packages/bumpy/src/commands/publish.ts index e67de67..9bc4ac3 100644 --- a/packages/bumpy/src/commands/publish.ts +++ b/packages/bumpy/src/commands/publish.ts @@ -7,6 +7,7 @@ import { publishPackages } from '../core/publish-pipeline.ts'; import { createIndividualReleases, createAggregateRelease } 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 type { BumpyConfig, PackageConfig, ReleasePlan, PlannedRelease, WorkspacePackage } from '../types.ts'; interface PublishCommandOptions { @@ -32,9 +33,9 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption process.exit(1); } - // Find packages that need publishing by checking which ones have versions - // not yet on the registry - let toPublish = await findUnpublishedPackages(packages, config); + // Find packages that need publishing — use cached plan from `ci plan` if available, + // otherwise query the registry + let toPublish = await findUnpublishedWithCache(rootDir, packages, config); // Apply filter if (opts.filter) { @@ -127,6 +128,79 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption } } +/** + * Try to load cached plan from `ci plan`. Returns the unpublished package names + * if the cache is valid, or null to fall back to registry lookups. + * + * Validates that every cached package exists in the workspace with the same version, + * so the cache can only filter — never fabricate — the set of packages. + */ +function loadCachedPlan(rootDir: string, packages: Map): Set | null { + const cachePath = `${rootDir}/${CI_PLAN_CACHE_PATH}`; + let raw: string; + try { + raw = require('node:fs').readFileSync(cachePath, 'utf-8'); + // Clean up cache file after reading + require('node:fs').unlinkSync(cachePath); + } catch { + return null; + } + + try { + const cached = JSON.parse(raw); + if (cached?.mode !== 'publish' || !Array.isArray(cached.releases)) return null; + + const names = new Set(); + for (const r of cached.releases) { + if (typeof r.name !== 'string' || typeof r.newVersion !== 'string') return null; + // Validate against workspace — reject if package doesn't exist or version doesn't match + const pkg = packages.get(r.name); + if (!pkg || pkg.version !== r.newVersion) { + log.dim(' ci plan cache is stale — falling back to registry lookups'); + return null; + } + names.add(r.name); + } + + log.dim(' Using cached plan from ci plan'); + return names; + } catch { + return null; + } +} + +/** + * Find unpublished packages, using the ci plan cache if available. + * Falls back to registry lookups if no cache or cache is invalid. + */ +async function findUnpublishedWithCache( + rootDir: string, + packages: Map, + config: BumpyConfig, +): Promise { + const cachedNames = loadCachedPlan(rootDir, packages); + if (cachedNames) { + // Build PlannedRelease entries directly from workspace data — no network needed + const unpublished: PlannedRelease[] = []; + for (const name of cachedNames) { + const pkg = packages.get(name)!; + unpublished.push({ + name, + type: 'patch', + oldVersion: pkg.version, + newVersion: pkg.version, + bumpFiles: [], + isDependencyBump: false, + isCascadeBump: false, + isGroupBump: false, + bumpSources: [], + }); + } + return unpublished; + } + return findUnpublishedPackages(packages, config); +} + /** * Find packages whose current version is not yet published. * @@ -135,7 +209,7 @@ export async function publishCommand(rootDir: string, opts: PublishCommandOption * 2. `skipNpmPublish` or custom `publishCommand` → check git tags * 3. Default → check npm registry via `npm info` */ -async function findUnpublishedPackages( +export async function findUnpublishedPackages( packages: Map, _config: BumpyConfig, ): Promise { From 94c7dd063f8c211be120feca1c77228bb03be4db Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 29 Apr 2026 22:58:10 -0700 Subject: [PATCH 2/4] Add bump file for ci plan command --- .bumpy/ci-plan-command.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .bumpy/ci-plan-command.md diff --git a/.bumpy/ci-plan-command.md b/.bumpy/ci-plan-command.md new file mode 100644 index 0000000..59e5776 --- /dev/null +++ b/.bumpy/ci-plan-command.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Add `bumpy ci plan` command for conditional CI builds From 3efb8b5a916576bd6fbd611892d2e26cfbb0c35c Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 29 Apr 2026 23:00:46 -0700 Subject: [PATCH 3/4] Change publishTargets to array of objects for extensibility Prepares for future multi-target publishing (npm, jsr, cargo, etc.) by using { type: string } objects instead of plain strings. Applied to both `ci plan` and `status --json` output. --- packages/bumpy/src/commands/ci.ts | 10 +++++----- packages/bumpy/src/commands/status.ts | 8 ++++---- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index dbf9ed0..3931504 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -229,7 +229,7 @@ interface PlanRelease { bumpFiles: string[]; isDependencyBump: boolean; isCascadeBump: boolean; - publishTargets: string[]; + publishTargets: Array<{ type: string }>; } interface PlanOutput { @@ -343,16 +343,16 @@ function formatPlanRelease( function getPublishTargets( pkg: { private: boolean; bumpy?: PackageConfig } | undefined, _config: BumpyConfig, -): string[] { +): Array<{ type: string }> { if (!pkg) return []; const pkgConfig = pkg.bumpy || {}; if (pkg.private && !pkgConfig.publishCommand) return []; - const targets: string[] = []; + const targets: Array<{ type: string }> = []; if (pkgConfig.publishCommand) { - targets.push('custom'); + targets.push({ type: 'custom' }); } if (!pkgConfig.publishCommand && !pkgConfig.skipNpmPublish) { - targets.push('npm'); + targets.push({ type: 'npm' }); } return targets; } diff --git a/packages/bumpy/src/commands/status.ts b/packages/bumpy/src/commands/status.ts index 4089a3d..12af0fa 100644 --- a/packages/bumpy/src/commands/status.ts +++ b/packages/bumpy/src/commands/status.ts @@ -166,16 +166,16 @@ function getPublishTargets( pkg: WorkspacePackage | undefined, pkgConfig: Partial, _config: BumpyConfig, -): string[] { +): Array<{ type: string }> { if (!pkg) return []; // Private packages with no custom command won't publish if (pkg.private && !pkgConfig.publishCommand) return []; - const targets: string[] = []; + const targets: Array<{ type: string }> = []; if (pkgConfig.publishCommand) { - targets.push('custom'); + targets.push({ type: 'custom' }); } if (!pkgConfig.publishCommand && !pkgConfig.skipNpmPublish) { - targets.push('npm'); + targets.push({ type: 'npm' }); } return targets; } From f9c084da830d8625ea4908259707c8f80cbf74f3 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Wed, 29 Apr 2026 23:04:24 -0700 Subject: [PATCH 4/4] Add conditional publish step example to release workflow --- .github/workflows/release.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cc519e3..e31f7dd 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -33,11 +33,18 @@ jobs: # ------------------------------- # 🐸 Plan first — detects mode and caches the result for ci release + # Outputs: mode (version-pr|publish|nothing), packages (comma-separated), json (full plan) - id: plan run: bunx @varlock/bumpy ci plan env: GH_TOKEN: ${{ github.token }} + # Example: conditionally run expensive steps only when publishing + # In your project, this is where you'd put build/compile/test steps + # that are only needed before a publish (not when updating the version PR) + - if: steps.plan.outputs.mode == 'publish' + run: echo "📦 Publish mode — packages to release:" && echo "${{ steps.plan.outputs.packages }}" + # Creates/updates release PR when PRs merge to main, publishes packages when release PR is merged - run: bunx @varlock/bumpy ci release env: