diff --git a/.bumpy/ci-mode-flag.md b/.bumpy/ci-mode-flag.md new file mode 100644 index 0000000..f2693f1 --- /dev/null +++ b/.bumpy/ci-mode-flag.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Add `--mode` flag to `bumpy ci release` for asserting the detected release mode (`version-pr` or `publish`). Enables split-job release workflows where each job fails loudly if the runtime state doesn't match what the job expects. Refactored `ReleaseOptions` to rename the existing `mode` field to `autoPublish: boolean` and add `assertMode`. `--mode` and `--auto-publish` cannot be combined. diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index cb2c442..8e58c5c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -8,21 +8,20 @@ concurrency: cancel-in-progress: false jobs: - release: + # Detect what `ci release` would do and gate downstream jobs accordingly. + # Runs with no write permissions and no publish credentials. + plan: runs-on: ubuntu-latest permissions: - contents: write - pull-requests: write - id-token: write # required for npm trusted publishing (OIDC) + contents: read + outputs: + mode: ${{ steps.plan.outputs.mode }} + packages: ${{ steps.plan.outputs.packages }} steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: oven-sh/setup-bun@v2 - # Node.js (npm) is needed for npm publish - - uses: actions/setup-node@v6 - with: - node-version: latest - run: bun install # --- You wont need this part --- @@ -32,21 +31,69 @@ jobs: - run: bun install # ------------------------------- - # 🐸 Plan first — detects mode and caches the result for ci release - # Outputs: mode (version-pr|publish|nothing), packages (comma-separated), json (full plan) + # 🐸 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 the Version Packages PR. No publish credentials — never sees + # id-token or npm secrets, so a malicious commit to main can't ride this job to publish. + version-pr: + needs: plan + if: needs.plan.outputs.mode == 'version-pr' + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + - run: bun install - # Creates/updates release PR when PRs merge to main, publishes packages when release PR is merged - - run: bunx @varlock/bumpy ci release + # --- You wont need this part --- + - run: bun run --filter @varlock/bumpy build + - run: bun install + # ------------------------------- + + - run: bunx @varlock/bumpy ci release --mode version-pr env: GH_TOKEN: ${{ github.token }} BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} # <- PAT so that version PR triggers CI + + # Publishes packages. Scoped to the `publish` environment — pin the npm trusted + # publisher to this environment name on npmjs.com so that an OIDC token requested + # from any other job (or a rogue workflow file) will be rejected by npm. + publish: + needs: plan + if: needs.plan.outputs.mode == 'publish' + runs-on: ubuntu-latest + environment: publish + permissions: + contents: write + id-token: write # required for npm trusted publishing (OIDC) + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + # Node.js (npm) is needed for npm publish + - uses: actions/setup-node@v6 + with: + node-version: latest + - run: bun install + + # --- You wont need this part --- + - run: bun run --filter @varlock/bumpy build + - run: bun install + # ------------------------------- + + - run: echo "📦 Publishing packages:" && echo "${{ needs.plan.outputs.packages }}" + + - run: bunx @varlock/bumpy ci release --mode publish + env: + GH_TOKEN: ${{ github.token }} + # We dont use the default GH token so that further workflows can be triggred by GH release events + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} diff --git a/docs/cli.md b/docs/cli.md index a7740c3..e8957af 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -223,7 +223,7 @@ CI command for releases. Has two modes: **Version PR mode (default):** If pending bump files exist, creates or updates a "Version Packages" PR with all version bumps and changelog updates. If the current push is the Version Packages PR being merged, publishes the new versions, creates git tags, and creates GitHub releases. -**Auto-publish mode (`--auto-publish`):** Versions and publishes directly on merge without an intermediate PR. +**Auto-publish mode (`--auto-publish`):** Versions and publishes directly on merge without an intermediate PR. **Not recommended** — you lose the review/preview step on version bumps, and the job needs both PR-writing and publish credentials at once, which defeats the security split between version-PR and publish jobs. ```bash bumpy ci release @@ -231,11 +231,12 @@ bumpy ci release --auto-publish bumpy ci release --auto-publish --tag beta ``` -| Flag | Description | -| ----------------- | ---------------------------------------------------------- | -| `--auto-publish` | Version + publish directly instead of creating a PR | -| `--tag ` | npm dist-tag (for `--auto-publish`) | -| `--branch ` | Version PR branch name (default: `bumpy/version-packages`) | +| Flag | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--mode ` | Assert detected mode: `version-pr` or `publish`. Errors if the detected mode differs. Use to gate split-job workflows so a job can't silently fall into the wrong path. | +| `--auto-publish` | Version + publish directly instead of creating a PR | +| `--tag ` | npm dist-tag (for `--auto-publish`) | +| `--branch ` | Version PR branch name (default: `bumpy/version-packages`) | Requires `GH_TOKEN`. When `BUMPY_GH_TOKEN` is set, it is automatically used to push the version branch and create/edit the PR so that PR workflows trigger (see [GitHub Actions setup](github-actions.md#token-setup)). diff --git a/docs/github-actions.md b/docs/github-actions.md index ca458bd..be96b6b 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -1,14 +1,14 @@ # GitHub Actions Setup -Bumpy handles CI automation with two commands — no separate GitHub Action or bot to install. Just call `bumpy ci` directly in your workflows. +Bumpy handles CI automation through its `bumpy ci` subcommands — no separate GitHub Action or bot to install. Just call `bumpy ci` directly in your workflows. ## Overview -| 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. | +| 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 gate downstream jobs. | +| `bumpy ci release` | `push` to main | Either creates/updates the "Version Packages" PR (if bump files are present) or publishes packages, tags, and GitHub releases (if just versioned). | ## PR check workflow @@ -31,11 +31,9 @@ jobs: GH_TOKEN: ${{ github.token }} ``` -## Release workflow +## Release workflow (recommended: split jobs) -### Trusted publishing (OIDC — recommended) - -No `NPM_TOKEN` secret needed. Requires npm >= 11.5.1 for OIDC (>= 11.15.0 for staged publishing) — add `npm install -g npm@latest` since even Node latest may not ship with a new enough npm. +The recommended release workflow splits version-PR maintenance from publishing into separate jobs. Only the publish job carries `id-token: write` and npm credentials, and it runs inside a GitHub Environment — so a rogue workflow elsewhere in the repo can't request an OIDC token that npm will accept. ```yaml # .github/workflows/bumpy-release.yml @@ -49,11 +47,52 @@ concurrency: cancel-in-progress: false jobs: - release: + # Detect what `ci release` would do — no write permissions, no publish credentials. + plan: + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + mode: ${{ steps.plan.outputs.mode }} + packages: ${{ steps.plan.outputs.packages }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - id: plan + run: bunx @varlock/bumpy ci plan + env: + GH_TOKEN: ${{ github.token }} + + # Creates/updates the Version Packages PR. No publish credentials. + version-pr: + needs: plan + if: needs.plan.outputs.mode == 'version-pr' runs-on: ubuntu-latest permissions: contents: write pull-requests: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: oven-sh/setup-bun@v2 + - run: bun install + - run: bunx @varlock/bumpy ci release --mode version-pr + env: + GH_TOKEN: ${{ github.token }} + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} # so the version PR triggers CI + + # Publishes packages. Scoped to the `publish` environment. + publish: + needs: plan + if: needs.plan.outputs.mode == 'publish' + runs-on: ubuntu-latest + environment: publish + permissions: + contents: write id-token: write # required for npm trusted publishing (OIDC) and provenance steps: - uses: actions/checkout@v6 @@ -65,13 +104,26 @@ jobs: node-version: latest - run: npm install -g npm@latest # ensure npm >= 11.15.0 for OIDC/staged publishing - run: bun install - - run: bunx @varlock/bumpy ci release + # Expensive build steps that only matter before publish go here: + # - run: bun run build + - run: bunx @varlock/bumpy ci release --mode publish env: GH_TOKEN: ${{ github.token }} - BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} # so `release: published` workflows trigger ``` -**Trusted publishing setup:** Configure each package on [npmjs.com](https://docs.npmjs.com/trusted-publishers/) → Package Settings → Trusted Publishers → GitHub Actions. Specify your org/user, repo, and the workflow filename (`bumpy-release.yml`). +**How the three jobs interact:** + +- `plan` runs `bumpy ci plan` to determine whether the current push should update the Version Packages PR (`version-pr`), publish unpublished packages (`publish`), or do nothing. +- Only one of `version-pr` or `publish` runs per push. The other is skipped via the `if:` condition. +- The `--mode` flag on `ci release` asserts that the detected mode matches what each job expects — if the runtime state ever drifts, the job fails loudly instead of silently doing the wrong thing. +- Expensive build steps (compilation, tests, bundling) only run inside the `publish` job, so PR merges that just maintain the version PR stay cheap. + +### One-time setup + +1. **Create the `publish` environment** in repo Settings → Environments. GitHub auto-creates it on the first run, but creating it manually lets you add protection rules (required reviewers, branch restrictions to `main` only) before any release runs. +2. **Pin the npm trusted publisher to environment `publish`** on each package's npmjs.com settings → Trusted Publishers → GitHub Actions. Set the environment field to `publish`. This binds the OIDC trust to that specific environment — even if someone adds a rogue workflow file, npm will reject any token request that doesn't carry the `publish` environment claim. +3. **Set `BUMPY_GH_TOKEN`** — see [Token setup](#token-setup) below. **Recommended publish config** — enable provenance and staged publishing for maximum security: @@ -86,9 +138,30 @@ jobs: > **Staged publishing:** With `npmStaged` enabled, bumpy uses `npm stage publish` to stage packages on npmjs.com, requiring manual 2FA approval before they go live — even if your CI credentials are compromised, nothing gets published without maintainer approval. See the [staged publishing docs](./configuration.md#staged-publishing) for details. -### Token-based auth (NPM_TOKEN) +### Using `NPM_TOKEN` instead of OIDC -If you can't use trusted publishing, use an npm access token instead: +If you can't use trusted publishing, swap `id-token: write` for an `NPM_TOKEN` secret. Scope the secret to the `publish` environment (repo Settings → Environments → publish → Add secret) so only this job can read it: + +```yaml +publish: + needs: plan + if: needs.plan.outputs.mode == 'publish' + runs-on: ubuntu-latest + environment: publish + permissions: + contents: write + steps: + # ... checkout/setup-bun/setup-node/install steps ... + - run: bunx @varlock/bumpy ci release --mode publish + env: + GH_TOKEN: ${{ github.token }} + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} +``` + +## Release workflow (simplified single-job) + +For simpler setups, you can run everything in a single job. `bumpy ci release` will smart-route between version-PR and publish based on the current state. ```yaml # .github/workflows/bumpy-release.yml @@ -107,20 +180,26 @@ jobs: permissions: contents: write pull-requests: write + id-token: write # required for npm trusted publishing (OIDC) steps: - uses: actions/checkout@v6 with: fetch-depth: 0 - uses: oven-sh/setup-bun@v2 + - uses: actions/setup-node@v6 + with: + node-version: latest + - run: npm install -g npm@latest - run: bun install - run: bunx @varlock/bumpy ci release env: GH_TOKEN: ${{ github.token }} - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} ``` -### Auto-publish mode +**Trade-off:** this is the shortest workflow you can write, but `id-token: write` and any publish secrets are exposed on every push to main — including pushes that only update the version PR. The split-job workflow above scopes those credentials to the publish step only. Prefer the split workflow unless you have a strong reason not to. + +## Auto-publish mode (not recommended) Instead of the two-step flow (version PR → merge → publish), you can version and publish directly on merge: @@ -128,55 +207,16 @@ 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. +This is **not recommended** for two reasons: -`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. +- You lose the preview/review step. Every merge to main with a bump file ships immediately — no chance to catch a wrong bump level or unintended release in the Version Packages PR. +- The job needs `pull-requests: write` _and_ publish credentials (OIDC / `NPM_TOKEN`) in the same step. This rules out the split-job pattern that scopes publish credentials to a dedicated job/environment. -| Output | Description | -| ---------- | ------------------------------------------------------------- | -| `mode` | `version-pr`, `publish`, or `nothing` | -| `packages` | JSON array of package names (for `fromJSON()` + `contains()`) | -| `json` | Full JSON output (for `fromJSON()`) | +If you want fewer steps in your release flow, prefer the [split-job workflow](#release-workflow-recommended-split-jobs) — it's not more code on your side, and it keeps the security boundary intact. -### Basic: skip builds unless publishing +## Advanced: per-package conditional builds -```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: latest - - run: npm install -g npm@latest - - 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 +If you have one expensive package whose build you only want to run when that package itself is being released, use `ci plan`'s `packages` output to gate per-package steps: ```yaml - id: plan @@ -184,11 +224,19 @@ jobs: env: GH_TOKEN: ${{ github.token }} -# Build only specific packages that are being released +# Build only when this specific package is being released - if: contains(fromJSON(steps.plan.outputs.packages), 'my-expensive-package') run: bun run build --filter=my-expensive-package ``` +`ci plan` outputs: + +| Output | Description | +| ---------- | ------------------------------------------------------------- | +| `mode` | `version-pr`, `publish`, or `nothing` | +| `packages` | JSON array of package names (for `fromJSON()` + `contains()`) | +| `json` | Full JSON output (for `fromJSON()`) | + ## 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. @@ -205,21 +253,19 @@ This is included in all the workflow examples above. ### `GH_TOKEN` (required) -The default `${{ github.token }}` provides the basic permissions needed for both `ci check` and `ci release`. +The default `${{ github.token }}` covers general API access (registry lookups, reading PRs, posting comments). -**Permissions needed:** +**Permissions needed per job:** -- `pull-requests: write` — for posting PR comments and creating the version PR -- `contents: write` — for pushing commits and tags (release workflow only) -- `id-token: write` — for npm trusted publishing / OIDC (release workflow only) +- `pull-requests: write` — for posting PR comments (`ci check`) or creating the version PR (`version-pr` job) +- `contents: write` — for pushing commits and tags (release jobs) +- `id-token: write` — for npm trusted publishing / OIDC (publish job only) ### `BUMPY_GH_TOKEN` (recommended) GitHub's anti-recursion guard prevents PRs created by the default `github.token` from triggering other workflows. This means your regular CI workflows (tests, linting, etc.) won't run automatically on the Version Packages PR — so you can't verify that the version bumps don't break anything before merging. -To fix this, provide a `BUMPY_GH_TOKEN` using either a **fine-grained PAT** or a **GitHub App token**. Bumpy uses this token to push the version branch, which allows your CI workflows to trigger normally. - -When `BUMPY_GH_TOKEN` is set, bumpy automatically uses it for git push operations and for creating/editing the version PR. PR comments always use the default `GH_TOKEN` so they appear from `github-actions[bot]`. +To fix this, provide a `BUMPY_GH_TOKEN` using either a **fine-grained PAT** or a **GitHub App token**. Bumpy uses this token selectively — only for the specific operations where bypassing the anti-recursion guard matters (pushing the version branch, creating the version PR, creating the GitHub release). Everything else continues to use the default `GH_TOKEN`. > **Note:** If you're using a developer's personal PAT, the version PR will be authored by that developer. Consider using a dedicated bot account or GitHub App so the developer can still review and approve the PR. @@ -258,12 +304,12 @@ For organizations, a GitHub App avoids tying automation to a personal account: ### `NPM_TOKEN` (if not using trusted publishing) -A classic npm access token. Create one at [npmjs.com → Access Tokens](https://www.npmjs.com/settings/~/tokens) and add it as a repository secret named `NPM_TOKEN`. +A classic npm access token. Create one at [npmjs.com → Access Tokens](https://www.npmjs.com/settings/~/tokens) and add it as a secret on the `publish` environment (repo Settings → Environments → publish → Add secret) so only the publish job can read it. ## Environment variables summary -| Variable | Required | Used by | Description | -| ---------------- | ----------------- | ------------------------ | ----------------------------------------------------------------- | -| `GH_TOKEN` | Yes | `ci check`, `ci release` | GitHub token for API access | -| `BUMPY_GH_TOKEN` | Recommended | `ci check`, `ci release` | PAT or App token — used for push, and optionally for PRs/comments | -| `NPM_TOKEN` | If not using OIDC | `ci release` | npm access token for publishing | +| Variable | Required | Used by | Description | +| ---------------- | ----------------- | ------------------------ | ----------------------------------------------------------------------------- | +| `GH_TOKEN` | Yes | `ci check`, `ci release` | GitHub token for API access — `${{ github.token }}` is fine | +| `BUMPY_GH_TOKEN` | Recommended | `ci check`, `ci release` | PAT or App token — selectively used for ops where workflow-triggering matters | +| `NPM_TOKEN` | If not using OIDC | publish job | npm access token for publishing | diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index 89cffc7..429e000 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -117,9 +117,19 @@ async function main() { 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); + const assertModeFlag = ciFlags.mode; + const autoPublishFlag = ciFlags['auto-publish'] === true; + if (assertModeFlag !== undefined && assertModeFlag !== 'version-pr' && assertModeFlag !== 'publish') { + log.error(`Invalid --mode value: "${assertModeFlag}". Must be "version-pr" or "publish".`); + process.exit(1); + } + if (assertModeFlag !== undefined && autoPublishFlag) { + log.error('--mode and --auto-publish cannot be used together.'); + process.exit(1); + } await ciReleaseCommand(rootDir, { - mode, + autoPublish: autoPublishFlag, + assertMode: assertModeFlag as 'version-pr' | 'publish' | undefined, tag: ciFlags.tag as string | undefined, branch: ciFlags.branch as string | undefined, }); @@ -240,6 +250,7 @@ function printHelp() { --no-fail Warn only, never exit 1 CI release options: + --mode Assert detected mode: "version-pr" or "publish" (errors if mismatched) --auto-publish Version + publish directly (default: create version PR) --tag npm dist-tag for auto-publish --branch Branch name for version PR (default: bumpy/version-packages) diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index d438ed7..c821942 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -375,14 +375,17 @@ function writeGitHubOutput(key: string, value: string): void { // ---- ci release ---- interface ReleaseOptions { - mode: 'auto-publish' | 'version-pr'; + autoPublish?: boolean; // skip the version-PR step and version+publish in one shot + assertMode?: 'version-pr' | 'publish'; // refuse to run if detected mode doesn't match — see CiPlanMode tag?: string; // npm dist-tag for auto-publish branch?: string; // branch name for version PR (default: "bumpy/version-packages") } /** - * CI release: either auto-publish or create a version PR. - * Designed for merge-to-main workflows. + * CI release: either create a version PR (bump files present) or publish unpublished + * packages (no bump files — i.e. a version PR was just merged). Pass `autoPublish` to + * collapse both steps into a single push-to-main, or `assertMode` to refuse running + * when the detected state doesn't match expectations (used by split-job workflows). */ export async function ciReleaseCommand(rootDir: string, opts: ReleaseOptions): Promise { const config = await loadConfig(rootDir); @@ -398,34 +401,39 @@ export async function ciReleaseCommand(rootDir: string, opts: ReleaseOptions): P throw new Error('Bump file parse errors must be fixed before releasing.'); } - if (bumpFiles.length === 0) { - // No bump files — check if there are unpublished packages to publish - // (this handles the case where a version PR was just merged) - log.info('No pending bump files — checking for unpublished packages...'); - // Recover bump files deleted in the version commit so the formatter - // can generate proper GitHub release bodies - const recoveredBumpFiles = recoverDeletedBumpFiles(rootDir); - const { publishCommand } = await import('./publish.ts'); - await publishCommand(rootDir, { tag: opts.tag, recoveredBumpFiles }); - return; + // Determine detected mode. "version-pr" = bump files exist with real releases. + // "publish" = no bump files, or only none-only files (version PR just merged). + const plan = bumpFiles.length > 0 ? assembleReleasePlan(bumpFiles, packages, depGraph, config) : null; + const detectedMode: 'version-pr' | 'publish' = plan && plan.releases.length > 0 ? 'version-pr' : 'publish'; + + if (opts.assertMode && opts.assertMode !== detectedMode) { + throw new Error( + `Expected mode "${opts.assertMode}" but detected "${detectedMode}". ` + + `Either remove --mode, or gate this step on the output of "bumpy ci plan".`, + ); } - const plan = assembleReleasePlan(bumpFiles, packages, depGraph, config); - if (plan.releases.length === 0) { - // 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...'); + if (detectedMode === 'publish') { + // No bump files (or only none-only) — check for unpublished packages. + // Recover bump files deleted in the version commit so the formatter + // can generate proper GitHub release bodies. + const msg = + bumpFiles.length === 0 + ? 'No pending bump files — checking for unpublished packages...' + : 'Bump files found but no packages would be released — checking for unpublished packages...'; + log.info(msg); const recoveredBumpFiles = recoverDeletedBumpFiles(rootDir); const { publishCommand } = await import('./publish.ts'); await publishCommand(rootDir, { tag: opts.tag, recoveredBumpFiles }); return; } - if (opts.mode === 'auto-publish') { - await autoPublish(rootDir, config, plan, opts.tag); + // detectedMode === 'version-pr' — plan is non-null with releases + if (opts.autoPublish) { + await autoPublish(rootDir, config, plan!, opts.tag); } else { const packageDirs = new Map([...packages.values()].map((p) => [p.name, p.relativeDir])); - await createVersionPr(rootDir, plan, config, packageDirs, opts.branch); + await createVersionPr(rootDir, plan!, config, packageDirs, opts.branch); } }