diff --git a/.github/workflows/bumpy-check.yaml b/.github/workflows/bumpy-check.yaml index d71cbf71..34f5ccd5 100644 --- a/.github/workflows/bumpy-check.yaml +++ b/.github/workflows/bumpy-check.yaml @@ -1,21 +1,21 @@ name: Bumpy check -# Posts/updates the release-plan comment on every PR (including forks). +# PR half of bumpy's fork-safe release-plan comment (the 1.18+ split pattern). # -# Uses `pull_request_target` so the comment can be posted on fork PRs (a plain -# `pull_request` gives forks a read-only token, so `pull-requests: write` would -# be ineffective). Because that runs with the base repo's elevated token, the PR -# code is treated as untrusted DATA only: -# - the base branch is checked out at the root (trusted bunfig.toml / lockfile -# + the base package.json we read the pinned bumpy version from); -# - the PR head is checked out into ./pr, isolated and never executed; -# - bumpy is run from the root (so the PR's bunfig.toml/.npmrc can't redirect -# package resolution) and only reads the PR files via `--cwd ./pr`. -# ⚠️ DO NOT bun install / npm install / run any script from ./pr ⚠️ -on: pull_request_target +# Runs on plain `pull_request`, so on fork PRs this job gets a READ-ONLY token +# and cannot post comments — that's intentional. It only computes the release +# plan, gates on missing bump files, and writes the rendered comment to the +# `bumpy-comment` artifact (`ci check --emit-comment`). The privileged half that +# actually posts lives in bumpy-comment.yaml, which runs on `workflow_run` +# (trusted context, never executes PR code) and resolves the target PR from the +# trusted event — never from this artifact. +# +# Because the token here is read-only, it is safe to run bumpy against PR code: +# the worst a malicious fork's bunfig.toml/.npmrc/preload can do is corrupt its +# own release-plan output, with no write capability to abuse. +on: pull_request permissions: - pull-requests: write contents: read concurrency: @@ -23,33 +23,37 @@ concurrency: cancel-in-progress: true jobs: - check: + # Distinct job name (not "check") so the status context reads + # "Bumpy check / bumpy-check" and doesn't collide with other `check` jobs — + # this is the context to require in branch protection. + bumpy-check: runs-on: ubuntu-latest steps: - # Trusted base checkout at the root: provides the bunfig.toml / lockfile - # that govern how `bunx` below resolves and runs bumpy, plus the base - # package.json we read the pinned bumpy version from. - uses: actions/checkout@v7 with: - ref: main + fetch-depth: 0 persist-credentials: false - uses: oven-sh/setup-bun@v2 - # Untrusted PR head, isolated in ./pr — read as data, never executed. - - uses: actions/checkout@v7 - with: - ref: ${{ github.event.pull_request.head.sha }} - path: pr - persist-credentials: false - - # Resolve bumpy from the base package.json (trusted) and run it from the - # root against the PR files. The version is read straight into the bunx - # invocation (never written to $GITHUB_ENV) so there is no env-injection - # sink even though this is a pull_request_target workflow. + # Resolve the bumpy version pinned in package.json and run the PR check. + # A direct comment post is attempted but fails-soft on the read-only token + # (bumpy just warns); the rendered comment is written to ./bumpy-comment + # for the poster regardless. Exits non-zero only on the gate: a changed + # publishable package missing its bump file. - 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 + bunx "@varlock/bumpy@$VERSION" ci check --emit-comment ./bumpy-comment env: GH_TOKEN: ${{ github.token }} + + # Hand the rendered comment to the privileged poster. `always()` so the + # plan still posts even when the check fails the gate (missing bump file); + # bumpy always writes a (possibly empty) comment.md, so the artifact exists. + - name: Upload release-plan comment + if: always() + uses: actions/upload-artifact@v4 + with: + 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 00000000..6f493545 --- /dev/null +++ b/.github/workflows/bumpy-comment.yaml @@ -0,0 +1,62 @@ +name: Bumpy comment + +# Privileged half of bumpy's fork-safe release-plan comment (the 1.18+ split). +# +# The unprivileged "Bumpy check" workflow runs on `pull_request` (executing PR +# code with a read-only token) and uploads the rendered comment as the +# `bumpy-comment` artifact. This workflow runs on `workflow_run` — a trusted +# context that NEVER checks out or executes PR code — and posts that comment. +# +# SECURITY: the artifact body is untrusted (a fork produced it). Two things keep +# this safe: (1) it is downloaded to runner.temp, OUTSIDE the checkout, so it +# can't overwrite the trusted files (package.json etc.) we read below; and (2) +# bumpy uses the body only as comment TEXT and resolves the target PR from the +# trusted `workflow_run` event (head_sha) — never from the artifact — so a fork +# can't redirect the comment onto another PR. +on: + workflow_run: + workflows: ["Bumpy check"] + types: [completed] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: bumpy-comment-${{ github.event.workflow_run.head_branch }} + cancel-in-progress: true + +jobs: + comment: + # Only act on check runs that came from a PR; skip anything else. + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + steps: + # Trusted base checkout (default branch) — provides the package.json we + # read the pinned bumpy version from. PR code is never checked out here. + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: oven-sh/setup-bun@v2 + + # Pull the rendered comment from the triggering check run. Download to + # runner.temp (OUTSIDE the checkout) so the untrusted artifact can't + # overwrite trusted files — this is what CodeQL's actions/artifact-poisoning + # query wants. `continue-on-error` so a missing artifact just means there's + # nothing to post (ci comment no-ops). + - name: Download release-plan comment + uses: actions/download-artifact@v4 + continue-on-error: true + with: + name: bumpy-comment + path: ${{ runner.temp }}/bumpy-comment + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Post release-plan comment + run: | + VERSION=$(jq -r '.devDependencies["@varlock/bumpy"] // .dependencies["@varlock/bumpy"]' package.json | sed 's/[\^~]//') + bunx "@varlock/bumpy@$VERSION" ci comment --body-file "$RUNNER_TEMP/bumpy-comment/comment.md" + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 4467383f..3e40e80c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -40,9 +40,10 @@ jobs: - name: Enable turborepo build cache uses: rharkor/caching-for-turbo@56219402aacc0d06b650d898c222996dbc1191ec # v2.3.14 - # Note: the bumpy release-plan comment now lives in its own hardened - # workflow (.github/workflows/bumpy-check.yaml) so it can post on fork PRs - # without running PR-defined code with write permissions. + # Note: the bumpy release-plan check + comment live in their own workflows + # (bumpy-check.yaml emits the comment on `pull_request`; bumpy-comment.yaml + # posts it on `workflow_run`) so it can post on fork PRs without running + # PR-defined code with write permissions. # lint, build, tests --------------------------------- - name: ESLint diff --git a/bun.lock b/bun.lock index 06e0dca4..ad037580 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,7 @@ "@types/node": "catalog:", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", - "@varlock/bumpy": "^1.17.0", + "@varlock/bumpy": "^1.18.0", "@varlock/tsconfig": "workspace:*", "eslint": "^10.0.2", "eslint-plugin-es-x": "^9.5.0", @@ -1437,7 +1437,7 @@ "@varlock/bitwarden-plugin": ["@varlock/bitwarden-plugin@workspace:packages/plugins/bitwarden"], - "@varlock/bumpy": ["@varlock/bumpy@1.17.0", "", { "bin": { "bumpy": "dist/cli.mjs" } }, "sha512-EunYq1RGBCTWFoaakcNdgHhw+ncJ0y4NtbrELtwhnTHcy1g+wVbOJ1/0I7wg898LUFqo+/kAlZq6wfudA8KRyA=="], + "@varlock/bumpy": ["@varlock/bumpy@1.18.0", "", { "bin": { "bumpy": "dist/cli.mjs" } }, "sha512-0LBdmKG28V9xgg2clbxZbZG2Z94X/TEJ2sMgercYX0INj2Z84xPpk4/vy83i2+xA0kDxvafqDI7DetRnPGPIzw=="], "@varlock/ci-env-info": ["@varlock/ci-env-info@workspace:packages/ci-env-info"], diff --git a/package.json b/package.json index 31a74282..35b12510 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "@types/node": "catalog:", "@typescript-eslint/eslint-plugin": "^8.56.1", "@typescript-eslint/parser": "^8.56.1", - "@varlock/bumpy": "^1.17.0", + "@varlock/bumpy": "^1.18.0", "@varlock/tsconfig": "workspace:*", "eslint": "^10.0.2", "eslint-plugin-es-x": "^9.5.0",