From cccf86208ac8dc3dea6e07c65ac07c160d8ce5d1 Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Mon, 1 Jun 2026 22:59:23 -0700 Subject: [PATCH 1/3] feat(ci): split-job release workflow + --mode assertion Add a `--mode ` flag to `bumpy ci release` so each job in a split-job release workflow can assert its expected runtime state and fail loudly on drift instead of silently routing. Updates the project's own release.yaml to use the recommended pattern: a `plan` job (no write perms) gates a `version-pr` job (PR-only creds) and a `publish` job (scoped to a new `publish` GitHub Environment with id-token: write), so npm trusted-publisher OIDC can be pinned to the environment and NPM_TOKEN exposure can be scoped via env secrets. Internal: ReleaseOptions field `mode: 'auto-publish' | 'version-pr'` renamed to `autoPublish: boolean` for clarity; new `assertMode` field carries the assertion. `--mode` + `--auto-publish` together is rejected at the CLI level. Docs: github-actions.md restructured to lead with the split-job workflow + environment setup, with the single-job version kept as a simplified alternative. Auto-publish flagged as not recommended. --- .bumpy/ci-mode-flag.md | 5 + .github/workflows/release.yaml | 81 +++++++++--- docs/cli.md | 13 +- docs/github-actions.md | 202 ++++++++++++++++++------------ packages/bumpy/src/cli.ts | 15 ++- packages/bumpy/src/commands/ci.ts | 50 ++++---- 6 files changed, 242 insertions(+), 124 deletions(-) create mode 100644 .bumpy/ci-mode-flag.md 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); } } From fe31f8ad5509017ab60d8ac5a70dcb96cd6311fe Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 2 Jun 2026 10:23:54 -0700 Subject: [PATCH 2/3] rename --mode to --expect-mode for clarity The flag asserts the detected mode rather than setting it, so the name should reflect that. Reads more naturally in YAML where there's no surrounding context. Internal `assertMode` field name unchanged. --- .bumpy/ci-expect-mode-flag.md | 5 +++++ .bumpy/ci-mode-flag.md | 5 ----- .github/workflows/release.yaml | 4 ++-- docs/cli.md | 12 ++++++------ docs/github-actions.md | 24 ++++++++++++++++-------- packages/bumpy/src/cli.ts | 14 +++++++------- packages/bumpy/src/commands/ci.ts | 2 +- 7 files changed, 37 insertions(+), 29 deletions(-) create mode 100644 .bumpy/ci-expect-mode-flag.md delete mode 100644 .bumpy/ci-mode-flag.md diff --git a/.bumpy/ci-expect-mode-flag.md b/.bumpy/ci-expect-mode-flag.md new file mode 100644 index 0000000..1bb557b --- /dev/null +++ b/.bumpy/ci-expect-mode-flag.md @@ -0,0 +1,5 @@ +--- +'@varlock/bumpy': minor +--- + +Add `--expect-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`. `--expect-mode` and `--auto-publish` cannot be combined. diff --git a/.bumpy/ci-mode-flag.md b/.bumpy/ci-mode-flag.md deleted file mode 100644 index f2693f1..0000000 --- a/.bumpy/ci-mode-flag.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@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 8e58c5c..90db107 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -58,7 +58,7 @@ jobs: - run: bun install # ------------------------------- - - run: bunx @varlock/bumpy ci release --mode version-pr + - run: bunx @varlock/bumpy ci release --expect-mode version-pr env: GH_TOKEN: ${{ github.token }} BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} # <- PAT so that version PR triggers CI @@ -92,7 +92,7 @@ jobs: - run: echo "📦 Publishing packages:" && echo "${{ needs.plan.outputs.packages }}" - - run: bunx @varlock/bumpy ci release --mode publish + - run: bunx @varlock/bumpy ci release --expect-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 diff --git a/docs/cli.md b/docs/cli.md index e8957af..9af4faf 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -231,12 +231,12 @@ bumpy ci release --auto-publish bumpy ci release --auto-publish --tag beta ``` -| 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`) | +| Flag | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--expect-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 be96b6b..2cd7b71 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -80,7 +80,7 @@ jobs: fetch-depth: 0 - uses: oven-sh/setup-bun@v2 - run: bun install - - run: bunx @varlock/bumpy ci release --mode version-pr + - run: bunx @varlock/bumpy ci release --expect-mode version-pr env: GH_TOKEN: ${{ github.token }} BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} # so the version PR triggers CI @@ -106,7 +106,7 @@ jobs: - run: bun install # Expensive build steps that only matter before publish go here: # - run: bun run build - - run: bunx @varlock/bumpy ci release --mode publish + - run: bunx @varlock/bumpy ci release --expect-mode publish env: GH_TOKEN: ${{ github.token }} BUMPY_GH_TOKEN: ${{ secrets.BUMPY_GH_TOKEN }} # so `release: published` workflows trigger @@ -116,14 +116,22 @@ jobs: - `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. +- The `--expect-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 +### Required 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. +1. **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. +2. **Set `BUMPY_GH_TOKEN`** — see [Token setup](#token-setup) below. + +That's it — the `publish` environment auto-creates on the first publish run, so no manual GitHub setup is required. + +### Optional hardening: protection rules on the `publish` environment + +If you create the environment manually in repo Settings → Environments _before_ the first publish, you can attach protection rules: + +- **Restrict deployment branches to `main`** — recommended. Cheap defense in depth: non-`main` refs can never request an OIDC token from this environment, even if a workflow trigger is accidentally widened later. +- **Required reviewers** — optional. Adds a manual approval gate before each publish. Usually redundant if `npmStaged: true` is enabled (below), since you already have a 2FA approval gate on npmjs.com. **Recommended publish config** — enable provenance and staged publishing for maximum security: @@ -152,7 +160,7 @@ publish: contents: write steps: # ... checkout/setup-bun/setup-node/install steps ... - - run: bunx @varlock/bumpy ci release --mode publish + - run: bunx @varlock/bumpy ci release --expect-mode publish env: GH_TOKEN: ${{ github.token }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index 429e000..9d313a1 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -117,19 +117,19 @@ async function main() { await ciPlanCommand(rootDir); } else if (subcommand === 'release') { const { ciReleaseCommand } = await import('./commands/ci.ts'); - const assertModeFlag = ciFlags.mode; + const expectModeFlag = ciFlags['expect-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".`); + if (expectModeFlag !== undefined && expectModeFlag !== 'version-pr' && expectModeFlag !== 'publish') { + log.error(`Invalid --expect-mode value: "${expectModeFlag}". Must be "version-pr" or "publish".`); process.exit(1); } - if (assertModeFlag !== undefined && autoPublishFlag) { - log.error('--mode and --auto-publish cannot be used together.'); + if (expectModeFlag !== undefined && autoPublishFlag) { + log.error('--expect-mode and --auto-publish cannot be used together.'); process.exit(1); } await ciReleaseCommand(rootDir, { autoPublish: autoPublishFlag, - assertMode: assertModeFlag as 'version-pr' | 'publish' | undefined, + assertMode: expectModeFlag as 'version-pr' | 'publish' | undefined, tag: ciFlags.tag as string | undefined, branch: ciFlags.branch as string | undefined, }); @@ -250,7 +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) + --expect-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 c821942..178c90d 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -409,7 +409,7 @@ export async function ciReleaseCommand(rootDir: string, opts: ReleaseOptions): P 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".`, + `Either remove --expect-mode, or gate this step on the output of "bumpy ci plan".`, ); } From e053c9f0a1c9afeb938200208b0a0c8957c9bd5b Mon Sep 17 00:00:00 2001 From: Theo Ephraim Date: Tue, 2 Jun 2026 15:05:51 -0700 Subject: [PATCH 3/3] docs: de-emphasize --auto-publish and correct credential framing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior copy implied --auto-publish defeats a security boundary by combining PR-write and publish credentials. That's misleading — a single-job non-auto-publish workflow has the same credential surface, just split across two runs. The actual cost of --auto-publish is purely the loss of the Version Packages PR preview/review gate. Tightens the wording in cli.md/github-actions.md, drops the README mention, shrinks the github-actions.md section to a brief pointer, and adds a docstring on autoPublish() so future readers don't re-derive the wrong conclusion. --- README.md | 2 -- docs/cli.md | 2 +- docs/github-actions.md | 13 +------------ packages/bumpy/src/cli.ts | 3 +++ packages/bumpy/src/commands/ci.ts | 12 ++++++++++++ 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 46a9f3b..a050540 100644 --- a/README.md +++ b/README.md @@ -171,8 +171,6 @@ jobs: -You can also use `bumpy ci release --auto-publish` to version + publish directly on merge without the intermediate PR. - ### Token setup The default `github.token` works for basic functionality, but GitHub's anti-recursion guard means PRs created by the default token won't trigger other workflows - so your regular CI (tests, linting, etc.) won't run automatically on the Version Packages PR. To fix this, provide a `BUMPY_GH_TOKEN` secret using either a **fine-grained PAT** or a **GitHub App token**. See the [full token setup guide](https://github.com/dmno-dev/bumpy/blob/main/docs/github-actions.md#token-setup) for details. diff --git a/docs/cli.md b/docs/cli.md index 9af4faf..19780af 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. **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. +**Auto-publish mode (`--auto-publish`):** Versions and publishes directly on merge without an intermediate PR. **Not recommended** — you lose the version-PR preview/review gate, so every merge to main with a bump file ships immediately. It's also incompatible with the [split-job workflow](github-actions.md#release-workflow-recommended-split-jobs) (since both paths happen in one run). The credential surface itself is the same as a single-job non-auto-publish workflow — the cost here is purely the loss of the preview gate. ```bash bumpy ci release diff --git a/docs/github-actions.md b/docs/github-actions.md index 2cd7b71..f50bc54 100644 --- a/docs/github-actions.md +++ b/docs/github-actions.md @@ -209,18 +209,7 @@ jobs: ## Auto-publish mode (not recommended) -Instead of the two-step flow (version PR → merge → publish), you can version and publish directly on merge: - -```yaml -- run: bunx @varlock/bumpy ci release --auto-publish -``` - -This is **not recommended** for two reasons: - -- 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. - -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. +`bumpy ci release --auto-publish` collapses version + publish into a single run, skipping the Version Packages PR. This forfeits the preview/review gate on version bumps — every merge to main with a bump file ships immediately. It's also incompatible with the [split-job pattern](#release-workflow-recommended-split-jobs) above, since both paths run in one command. Prefer the default flow. See [the CLI reference](cli.md#bumpy-ci-release) if you still need it. ## Advanced: per-package conditional builds diff --git a/packages/bumpy/src/cli.ts b/packages/bumpy/src/cli.ts index 9d313a1..fdd4852 100644 --- a/packages/bumpy/src/cli.ts +++ b/packages/bumpy/src/cli.ts @@ -123,6 +123,9 @@ async function main() { log.error(`Invalid --expect-mode value: "${expectModeFlag}". Must be "version-pr" or "publish".`); process.exit(1); } + // --expect-mode is for split-job workflows where each job runs exactly one path + // (version-pr or publish). --auto-publish does both in one run, so there's no + // single "mode" to assert against. if (expectModeFlag !== undefined && autoPublishFlag) { log.error('--expect-mode and --auto-publish cannot be used together.'); process.exit(1); diff --git a/packages/bumpy/src/commands/ci.ts b/packages/bumpy/src/commands/ci.ts index 178c90d..4575316 100644 --- a/packages/bumpy/src/commands/ci.ts +++ b/packages/bumpy/src/commands/ci.ts @@ -439,6 +439,18 @@ export async function ciReleaseCommand(rootDir: string, opts: ReleaseOptions): P // ---- auto-publish mode ---- +/** + * "Auto-publish" mode: skip the Version Packages PR and ship version+publish in one run. + * + * The only thing forfeited vs. the default flow is the preview/review gate on version + * bumps. Credentials are NOT a differentiator — a single-job non-auto-publish workflow + * also carries both PR-write and publish creds, just split across two runs. The real + * credential separation comes from the split-job pattern, which is orthogonal to (and + * incompatible with) this flag, since this collapses both paths into one execution. + * + * That incompatibility is also why --auto-publish and --expect-mode are mutually exclusive: + * --expect-mode is for split-job workflows where each job runs exactly one path. + */ async function autoPublish(rootDir: string, config: BumpyConfig, plan: ReleasePlan, tag?: string): Promise { log.step('Running bumpy version...'); const { versionCommand } = await import('./version.ts');