diff --git a/.bumpy/fork-comment-workflow-run-split.md b/.bumpy/fork-comment-workflow-run-split.md
new file mode 100644
index 0000000..b5aa73a
--- /dev/null
+++ b/.bumpy/fork-comment-workflow-run-split.md
@@ -0,0 +1,5 @@
+---
+'@varlock/bumpy': minor
+---
+
+Add a `pull_request` + `workflow_run` option for commenting on fork PRs, so the privileged half never touches fork code. `bumpy ci check --emit-comment
` renders the release-plan comment to `/comment.md` for upload as an artifact, and a new `bumpy ci comment --body-file ` posts it from a `workflow_run` job. The target PR is resolved from the trusted `workflow_run` event (`head_sha`), never from the (untrusted) artifact.
diff --git a/.github/workflows/bumpy-check.yaml b/.github/workflows/bumpy-check.yaml
index 1633ef5..436e40b 100644
--- a/.github/workflows/bumpy-check.yaml
+++ b/.github/workflows/bumpy-check.yaml
@@ -1,69 +1,38 @@
-# 🐸 Bumpy CI check
-# checks for missing bump files and posts/updates a PR comment with the release plan
+# 🐸 Bumpy CI check (dogfood)
+# Runs our own unreleased bumpy on every PR and renders the release-plan comment as an
+# artifact. Runs on the UNPRIVILEGED `pull_request` event, so it's safe to build and run
+# the PR's own bumpy (fork or not) — there's no write token or secrets to protect here.
+# Posting the comment on fork PRs is the privileged half and lives in bumpy-comment.yaml
+# (workflow_run). A normal project just adds
+# bunx @varlock/bumpy ci check --emit-comment ./bumpy-comment
+# to its existing CI workflow.
#
-# ⚠️ NOTE - DO NOT COPY THIS FILE
-# instead look at the recommended workflow in the docs
+# ⚠️ DO NOT COPY THIS FILE — see the recommended setup in the docs:
# ➡️ https://bumpy.varlock.dev/blob/main/docs/github-actions.md ⬅️
-#
-# This repo builds and runs its OWN unreleased bumpy so we dogfood the current
-# CLI on every PR. Two jobs, split by trust level:
-# - non-fork PRs build and run the PR's OWN bumpy, so a PR previews its own
-# ci-check changes. Safe because the code comes from this repo.
-# - fork PRs build and run MAIN's bumpy and only READ the PR via `--cwd ./pr`,
-# so no untrusted code ever touches the pull_request_target write token.
-# A normal project just runs `bunx @varlock/bumpy@latest ci check --cwd ./pr`.
-
name: Bumpy Check
-on: pull_request_target # < necessary so it can post comments on fork PRs
+on: pull_request
permissions:
- pull-requests: write
+ pull-requests: write # same-repo PRs comment directly; fork PRs are read-only (the poster handles those)
contents: read
jobs:
- # Non-fork PRs (trusted): build and run the PR's OWN bumpy so it dogfoods its
- # own changes. `--cwd .` acknowledges that the current checkout is trusted.
- check-local:
- if: github.event.pull_request.head.repo.full_name == github.repository
+ check:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v6
+ - uses: actions/checkout@v7
with:
- ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0 # history to diff bump files against the PR base branch
- uses: oven-sh/setup-bun@v2
- run: bun install
- # Build first since we run the local built version of bumpy.
- run: bun run --filter @varlock/bumpy build
- # Re-install so the freshly-built CLI bin is linked.
- - run: bun install
- - run: bunx @varlock/bumpy ci check --cwd .
+ - run: bun install # link the freshly-built CLI bin
+ - run: bunx @varlock/bumpy ci check --emit-comment ./bumpy-comment
env:
GH_TOKEN: ${{ github.token }}
-
- # Fork PRs (untrusted): build and run MAIN's bumpy from a trusted checkout and
- # only READ the PR head via `--cwd ./pr` — never install/build/run fork code.
- check-fork:
- if: github.event.pull_request.head.repo.full_name != github.repository
- runs-on: ubuntu-latest
- steps:
- # TRUSTED base checkout (main) — bumpy is built and run from here.
- - uses: actions/checkout@v6
- with:
- ref: main
- persist-credentials: false
- # UNTRUSTED PR head into ./pr — only READ via --cwd, never built or run.
- - uses: actions/checkout@v6
+ - uses: actions/upload-artifact@v4
+ if: always() # upload even when the check fails — the comment explains why
with:
- ref: ${{ github.event.pull_request.head.sha }}
- path: pr
- persist-credentials: false
- - uses: oven-sh/setup-bun@v2
- - run: bun install
- - run: bun run --filter @varlock/bumpy build
- - run: bun install
- # bunx runs from the trusted root; bumpy reads the PR's bump files via --cwd.
- - run: bunx @varlock/bumpy ci check --cwd ./pr
- env:
- GH_TOKEN: ${{ github.token }}
+ name: bumpy-comment
+ path: ./bumpy-comment
diff --git a/.github/workflows/bumpy-comment.yaml b/.github/workflows/bumpy-comment.yaml
new file mode 100644
index 0000000..f1ff705
--- /dev/null
+++ b/.github/workflows/bumpy-comment.yaml
@@ -0,0 +1,40 @@
+# 🐸 Bumpy PR comment (dogfood)
+# Privileged half of the fork-comment split: posts the release-plan comment that
+# bumpy-check.yaml rendered. Triggered after that workflow completes. It NEVER checks
+# out or runs PR code — it builds MAIN's bumpy and posts a pre-rendered body to the PR
+# it resolves from the trusted workflow_run.head_sha. A normal project would just run
+# `bunx @varlock/bumpy ci comment` instead of building from source.
+#
+# ⚠️ DO NOT COPY THIS FILE — see the recommended setup in the docs:
+# ➡️ https://bumpy.varlock.dev/blob/main/docs/github-actions.md ⬅️
+name: Bumpy PR Comment
+
+on:
+ workflow_run:
+ workflows: ['Bumpy Check']
+ types: [completed]
+
+permissions:
+ pull-requests: write
+
+jobs:
+ comment:
+ runs-on: ubuntu-latest
+ steps:
+ # TRUSTED: default branch (main) only — never the PR. Build our own bumpy.
+ - uses: actions/checkout@v7
+ - uses: oven-sh/setup-bun@v2
+ - run: bun install
+ - run: bun run --filter @varlock/bumpy build
+ - run: bun install # link the freshly-built CLI bin
+ # Download AFTER checkout — checkout cleans the workspace and would wipe it.
+ - uses: actions/download-artifact@v4
+ continue-on-error: true # the check may not have produced a comment
+ with:
+ name: bumpy-comment
+ path: ./bumpy-comment
+ run-id: ${{ github.event.workflow_run.id }}
+ github-token: ${{ github.token }}
+ - run: bunx @varlock/bumpy ci comment --body-file ./bumpy-comment/comment.md
+ env:
+ GH_TOKEN: ${{ github.token }}
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index b05ffaf..da8b7e7 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -14,5 +14,6 @@ jobs:
- run: git config --global user.name "CI" && git config --global user.email "ci@example.com"
- run: bun run test
- # NOTE: `bumpy ci check` lives in .github/workflows/bumpy-check.yaml
+ # NOTE: `bumpy ci check` runs in .github/workflows/bumpy-check.yaml (pull_request),
+ # and the fork-PR comment is posted from .github/workflows/bumpy-comment.yaml (workflow_run).
# see ➡️ https://bumpy.varlock.dev/blob/main/docs/github-actions.md ⬅️
diff --git a/docs/cli.md b/docs/cli.md
index 7b79741..068224f 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -180,18 +180,35 @@ CI command for PR checks. Computes the release plan from bump files changed in t
bumpy ci check
bumpy ci check --strict
bumpy ci check --no-fail
+bumpy ci check --emit-comment ./bumpy-comment
```
-| Flag | Description |
-| ----------- | ---------------------------------------------------------------- |
-| `--comment` | Force PR comment on or off (default: auto-detect CI environment) |
-| `--strict` | Fail if any changed package is not covered by a bump file |
-| `--no-fail` | Warn only, never exit non-zero |
+| Flag | Description |
+| ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| `--comment` | Force PR comment on or off (default: auto-detect CI environment) |
+| `--strict` | Fail if any changed package is not covered by a bump file |
+| `--no-fail` | Warn only, never exit non-zero |
+| `--emit-comment ` | Also write the rendered comment to `/comment.md` for a downstream [`ci comment`](#bumpy-ci-comment) to post (the fork-comment split) |
Requires `GH_TOKEN` environment variable (automatically available in GitHub Actions).
**On channel and promotion PRs:** for a PR targeting a [prerelease channel](prereleases.md) branch, the comment is channel-aware — it shows the prerelease plan (`-.x` versions) and the target dist-tag rather than implying a stable release. For a **promotion PR** (a channel branch → `main`, or a graduation like `alpha` → `beta`), the check reads the cycle's already-shipped bump files from `.bumpy//` and shows the consolidated stable plan, calling out that merging ends the prerelease cycle. (Feature PRs that _target_ a channel branch are checked against that branch, so only the PR's own new bump files count — see [`--base`](#bumpy-check) on the local `check` command for the equivalent locally.)
+## `bumpy ci comment`
+
+Posts a pre-rendered comment (from `ci check --emit-comment`) to a PR. This is the privileged half of the [fork-comment split](github-actions.md#commenting-on-fork-prs): your `pull_request` check renders the comment as an artifact, and this command — run from a `workflow_run` job — posts it.
+
+```bash
+bumpy ci comment --body-file ./bumpy-comment/comment.md
+```
+
+| Flag | Description |
+| -------------------- | ------------------------------------------------------------------ |
+| `--body-file ` | Path to the rendered comment body (required) |
+| `--pr ` | Target PR number (default: resolved from the `workflow_run` event) |
+
+It needs no checkout and no bumpy project — it only posts. Under `workflow_run` it resolves the target PR from the **trusted** event (`head_sha`), never from the artifact, and treats the body as untrusted text. A missing or empty body file is a no-op. Requires `GH_TOKEN`.
+
## `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.
diff --git a/docs/github-actions.md b/docs/github-actions.md
index 9733d9f..a550c7f 100644
--- a/docs/github-actions.md
+++ b/docs/github-actions.md
@@ -16,131 +16,87 @@ These commands facilitate the following:
>
> The version-resolution shell snippets work as-is regardless of package manager — they only depend on `jq` and `git`, both preinstalled on GitHub-hosted runners.
-## PR check workflow
+## PR check
-Posts/updates the release-plan comment on every PR, including PRs from forks. **Copy it as-is and you're covered** — it's structured so nothing in a fork PR can influence how bumpy is fetched or run. (If you change `main` to your own base branch, that's the only edit most repos need. See the security notes below before restructuring it.)
+`bumpy ci check` confirms every PR carries a bump file and posts a release-plan comment showing what will be released. The simplest setup is one step in your existing PR workflow (or a new one triggered `on: pull_request`):
```yaml
-# .github/workflows/bumpy-check.yaml
-name: Bumpy Check
-
-on: pull_request_target # so it can post comments on fork PRs
-
-permissions:
- pull-requests: write
- contents: read
-
-jobs:
- check:
- runs-on: ubuntu-latest
- steps:
- # 1. TRUSTED checkout of the base branch — bumpy is fetched and run from
- # here, so the package-manager config in effect (bunfig.toml, .npmrc)
- # is YOURS, not the PR's. Hardcoded "main" (the PR controls its own
- # base ref); change it to your base branch.
- - uses: actions/checkout@v6
- with:
- ref: main
- persist-credentials: false
-
- # 2. UNTRUSTED PR head into ./pr — only READ from here, never run it.
- - uses: actions/checkout@v6
- with:
- ref: ${{ github.event.pull_request.head.sha }}
- path: pr
- persist-credentials: false
-
- - uses: oven-sh/setup-bun@v2
-
- # ⚠️ DO NOT bun install / npm install / run any script from ./pr ⚠️
-
- # Read bumpy's version from the TRUSTED base checkout (never from ./pr) and
- # run it in one step. bunx runs from the trusted root; `--cwd ./pr` only
- # points bumpy at the PR tree to read its bump files. The version is folded
- # straight into the command (not written to $GITHUB_ENV) to avoid CodeQL's
- # actions/envvar-injection sink; quote it so a malformed value can't inject.
- - name: Bumpy release-plan check
- run: |
- VERSION=$(jq -r '.devDependencies["@varlock/bumpy"] // .dependencies["@varlock/bumpy"]' package.json | sed 's/[\^~]//')
- bunx "@varlock/bumpy@$VERSION" ci check --cwd ./pr
- env:
- GH_TOKEN: ${{ github.token }}
-```
-
-### ⚠️ Security essentials
-
-`pull_request_target` carries a **write token and secrets even on fork PRs** — that's what lets it comment on forks, and why a PR author must never be able to influence what runs. The workflow above handles this; two rules to preserve if you adapt it:
-
-- **Never execute PR code** — no `bun install` / `npm install` (postinstall scripts run), no `bun run