diff --git a/.github/scripts/claude-code-review-workflow-contract_test.py b/.github/scripts/claude-code-review-workflow-contract_test.py index d894570235..5186d9b21c 100644 --- a/.github/scripts/claude-code-review-workflow-contract_test.py +++ b/.github/scripts/claude-code-review-workflow-contract_test.py @@ -9,6 +9,7 @@ import unittest REPO_ROOT = Path(__file__).resolve().parents[2] WORKFLOW = REPO_ROOT / ".github" / "workflows" / "claude-code-review.yml" MENTION_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "claude.yml" +LINT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "lint-action-pinning.yml" ALLOWED_USERS_INPUT = "allowed_non_write_users: ${{ github.event.pull_request.user.login }}" @@ -136,6 +137,43 @@ class ClaudeCodeReviewWorkflowContractTest(unittest.TestCase): "to Claude's allowed_non_write_users input", ) + def test_allowlisted_fork_label_job_adds_both_approval_labels(self) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + _, separator, label_job = workflow.partition(" label-allowlisted-fork:") + label_job = label_job.partition("\n claude-review-fork:")[0] + + self.assertTrue(separator, "Allowlisted fork label job is missing") + self.assertIn("github.event_name == 'pull_request_target'", label_job) + self.assertIn("github.event.action == 'opened'", label_job) + self.assertIn( + "github.event.pull_request.head.repo.full_name != github.repository", + label_job, + ) + self.assertIn("vars.CLAUDE_REVIEW_ALLOWLIST != ''", label_job) + self.assertIn( + "contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)", + label_job, + ) + self.assertIn("issues: write", label_job) + self.assertIn("github.rest.issues.addLabels", label_job) + self.assertIn( + "const labels = ['safe-to-review', 'safe-to-test'];", + label_job, + ) + self.assertNotIn("actions/checkout", label_job) + + def test_lint_workflow_runs_preview_contract_test(self) -> None: + workflow = LINT_WORKFLOW.read_text(encoding="utf-8") + + self.assertIn( + ".github/scripts/preview-env-workflow-contract_test.py", + workflow, + ) + self.assertIn( + "python3 .github/scripts/preview-env-workflow-contract_test.py", + workflow, + ) + if __name__ == "__main__": unittest.main() diff --git a/.github/scripts/preview-env-workflow-contract_test.py b/.github/scripts/preview-env-workflow-contract_test.py new file mode 100644 index 0000000000..53c76ec458 --- /dev/null +++ b/.github/scripts/preview-env-workflow-contract_test.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Contract tests for the fork preview authorization workflow.""" + +from pathlib import Path +import unittest + + +REPO_ROOT = Path(__file__).resolve().parents[2] +WORKFLOW = REPO_ROOT / ".github" / "workflows" / "preview-env.yml" + + +class PreviewEnvironmentWorkflowContractTest(unittest.TestCase): + def test_claude_allowlist_authorizes_fork_preview_deployment(self) -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + _, separator, deploy_job = workflow.partition(" deploy-fork:") + + self.assertTrue(separator, "Fork preview deploy job is missing") + self.assertIn("github.event_name == 'pull_request_target'", deploy_job) + self.assertIn( + "github.event.pull_request.head.repo.full_name != github.repository", + deploy_job, + ) + self.assertIn( + "contains(github.event.pull_request.labels.*.name, 'safe-to-test')", + deploy_job, + ) + self.assertIn("vars.PREVIEW_ENV_ALLOWLIST != ''", deploy_job) + self.assertIn("vars.CLAUDE_REVIEW_ALLOWLIST != ''", deploy_job) + self.assertIn( + "contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login)", + deploy_job, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b919264f17..5b6d9798d0 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -73,6 +73,33 @@ jobs: --allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh pr comment:*),Bash(gh pr diff:*),Bash(gh pr view:*)" --disallowedTools "Edit,Write,MultiEdit,NotebookEdit,WebFetch,WebSearch,Bash(git add:*),Bash(git commit:*),Bash(git push:*),Bash(git rebase:*),Bash(git reset:*),Bash(gh pr edit:*),Bash(curl:*),Bash(wget:*)" + # Mark allowlisted fork PRs with the approval labels used by the review and + # preview workflows. The direct allowlist gates remain necessary because + # labels written with GITHUB_TOKEN do not start another workflow run. + label-allowlisted-fork: + if: > + github.event_name == 'pull_request_target' && + github.event.action == 'opened' && + github.event.pull_request.head.repo.full_name != github.repository && + vars.CLAUDE_REVIEW_ALLOWLIST != '' && + contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login) + runs-on: ubuntu-latest + permissions: + issues: write + + steps: + - name: Add automatic fork approval labels + uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7 + with: + script: | + const labels = ['safe-to-review', 'safe-to-test']; + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + labels, + }); + claude-review-fork: # Fork PRs only, via pull_request_target so secrets are available. # Two initial-review approval paths: diff --git a/.github/workflows/lint-action-pinning.yml b/.github/workflows/lint-action-pinning.yml index 957355b7b7..4d5a3f9264 100644 --- a/.github/workflows/lint-action-pinning.yml +++ b/.github/workflows/lint-action-pinning.yml @@ -8,6 +8,7 @@ on: - ".github/scripts/lint-action-pinning.py" - ".github/scripts/lint-action-pinning_test.py" - ".github/scripts/claude-code-review-workflow-contract_test.py" + - ".github/scripts/preview-env-workflow-contract_test.py" - ".github/scripts/collect-macos-desktop-diagnostics.sh" - ".github/scripts/release-workflow-contract_test.py" - ".github/release-signing-key.asc" @@ -34,6 +35,7 @@ on: - ".github/scripts/lint-action-pinning.py" - ".github/scripts/lint-action-pinning_test.py" - ".github/scripts/claude-code-review-workflow-contract_test.py" + - ".github/scripts/preview-env-workflow-contract_test.py" - ".github/scripts/collect-macos-desktop-diagnostics.sh" - ".github/scripts/release-workflow-contract_test.py" - ".github/release-signing-key.asc" @@ -79,6 +81,9 @@ jobs: - name: Test Claude Code review workflow contract run: python3 .github/scripts/claude-code-review-workflow-contract_test.py + - name: Test preview environment workflow contract + run: python3 .github/scripts/preview-env-workflow-contract_test.py + - name: Test release workflow contract run: python3 .github/scripts/release-workflow-contract_test.py diff --git a/.github/workflows/preview-env.yml b/.github/workflows/preview-env.yml index cbc4c6db23..1874c5e470 100644 --- a/.github/workflows/preview-env.yml +++ b/.github/workflows/preview-env.yml @@ -68,9 +68,11 @@ jobs: # ----------------------------------------------------------------------- # Deploy / update preview (fork PRs, gated by allowlist) # Uses pull_request_target so secrets are available. - # Two approval paths: + # Three approval paths: # - PREVIEW_ENV_ALLOWLIST repo variable (trusted login list, all events; # JSON array of logins) + # - CLAUDE_REVIEW_ALLOWLIST repo variable (trusted login list, all events; + # the Claude workflow also adds the approval labels on open) # - `safe-to-test` label (maintainer opt-in, per-commit). Explicitly # blocked on `synchronize` so a contributor's push cannot re-use a # stale label — the maintainer must re-apply it after each push for @@ -87,7 +89,8 @@ jobs: github.event_name == 'pull_request_target' && github.event.pull_request.head.repo.full_name != github.repository && ((github.event.action != 'synchronize' && contains(github.event.pull_request.labels.*.name, 'safe-to-test')) || - (vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login))) + (vars.PREVIEW_ENV_ALLOWLIST != '' && contains(fromJSON(vars.PREVIEW_ENV_ALLOWLIST), github.event.pull_request.user.login)) || + (vars.CLAUDE_REVIEW_ALLOWLIST != '' && contains(fromJSON(vars.CLAUDE_REVIEW_ALLOWLIST), github.event.pull_request.user.login))) runs-on: ubuntu-latest permissions: contents: read @@ -98,9 +101,11 @@ jobs: with: # pull_request_target checks out base by default; explicitly use PR head. # Safe: the preview CLI only reads files to build artifacts and posts comments. - # NOTE: The allowlist gate (safe-to-test, PREVIEW_ENV_ALLOWLIST) + # NOTE: The allowlist gate (safe-to-test, PREVIEW_ENV_ALLOWLIST, + # CLAUDE_REVIEW_ALLOWLIST) # authorizes running the fork's cmd/preview code with SPRITES_API_TOKEN and GH_TOKEN. - # The safe-to-test label must be re-applied after each new push (see strip-safe-to-test job). + # The safe-to-test label must be re-applied after each new push for + # the label-only path (see strip-safe-to-test job). repository: ${{ github.event.pull_request.head.repo.full_name }} ref: ${{ github.event.pull_request.head.sha }} diff --git a/docs/decisions/2026-08-07-claude-allowlist-label-bridge.md b/docs/decisions/2026-08-07-claude-allowlist-label-bridge.md new file mode 100644 index 0000000000..1a8df90515 --- /dev/null +++ b/docs/decisions/2026-08-07-claude-allowlist-label-bridge.md @@ -0,0 +1,60 @@ +# ADR-2026-08-07-claude-allowlist-label-bridge: Use the Claude Allowlist as a Trusted Preview Gate + +**Status:** accepted +**Date:** 2026-08-07 +**Area:** infra, workflow, security + +## Context + +Fork pull requests listed in `CLAUDE_REVIEW_ALLOWLIST` already receive an +automatic Claude review, while the preview workflow has a separate +`PREVIEW_ENV_ALLOWLIST` and a maintainer-applied `safe-to-test` label path. +Maintaining two lists makes a trusted contributor's pull request reviewable but +not previewable by default. Adding labels from a workflow with the repository's +`GITHUB_TOKEN` also does not create a new workflow run for the resulting +`labeled` event, so a label-only bridge would not reliably start the preview or +review workflows. + +## Decision + +On the `pull_request_target` `opened` event, a base-controlled job in +`.github/workflows/claude-code-review.yml` adds `safe-to-review` and +`safe-to-test` to fork pull requests whose author is present in +`CLAUDE_REVIEW_ALLOWLIST`. The job does not check out pull-request content and +uses only the issue-label API with `issues: write`. + +The existing direct allowlist gate remains the source of authorization for the +Claude review job. `.github/workflows/preview-env.yml` also treats +`CLAUDE_REVIEW_ALLOWLIST` as a trusted fork-preview gate for its existing +non-closed pull-request-target events. The labels remain visible approval +markers and continue to support the manual label paths; privileged workflow +execution does not depend on a recursive `labeled` event. + +The new labeling job applies only to fork pull requests on open. Existing +per-commit cleanup and manual re-approval behavior for `safe-to-test` remains +unchanged. + +## Consequences + +- Trusted fork contributors use one allowlist for automatic review labels and + preview eligibility, without a second duplicated identity list. +- `CLAUDE_REVIEW_ALLOWLIST` becomes a high-trust security boundary: every entry + is authorized to run the fork preview code with `SPRITES_API_TOKEN` on the + preview workflow's supported non-closed events. +- A missing label or label API failure is visible as a failed labeling job; + direct review and preview authorization still come from the allowlist gate. +- Labels added by `GITHUB_TOKEN` do not recursively run downstream workflows, + so direct conditions must remain synchronized with the labels' intended + meaning. + +## Alternatives Considered + +- Keeping `PREVIEW_ENV_ALLOWLIST` completely separate was rejected because it + requires maintainers to duplicate trusted contributor identities for the + requested review-plus-preview behavior. +- Relying only on the newly added labels to trigger existing workflows was + rejected because GitHub suppresses new workflow runs for most events emitted + by `GITHUB_TOKEN`, including label events. +- Introducing a personal access token or a new GitHub App solely to emit a + recursive label event was rejected because it adds another long-lived trust + credential and a broader workflow recursion surface. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 61348e0a53..d44042013a 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -127,3 +127,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-05-server-owned-quick-terminal-descriptors | [Server-Owned Quick Terminal Descriptors](2026-08-05-server-owned-quick-terminal-descriptors.md) | accepted | backend, frontend, protocol, security | 2026-08-05 | | 2026-08-05-nested-submodules-as-repository-scopes | [Model Nested Submodules as Repository Scopes](2026-08-05-nested-submodules-as-repository-scopes.md) | accepted | backend, frontend, protocol | 2026-08-05 | | 2026-08-05-homebrew-remote-helper-audit | [Preserve Remote Helpers in Homebrew Installs](2026-08-05-homebrew-remote-helper-audit.md) | accepted | infra, workflow | 2026-08-05 | +| 2026-08-07-claude-allowlist-label-bridge | [Use the Claude Allowlist as a Trusted Preview Gate](2026-08-07-claude-allowlist-label-bridge.md) | accepted | infra, workflow, security | 2026-08-07 | diff --git a/docs/plans/claude-fork-review-allowlist/plan.md b/docs/plans/claude-fork-review-allowlist/plan.md index d761082e1b..dba07e78ad 100644 --- a/docs/plans/claude-fork-review-allowlist/plan.md +++ b/docs/plans/claude-fork-review-allowlist/plan.md @@ -8,7 +8,7 @@ status: complete ## Overview -The fork review job already authorizes trusted contributors at its job-level condition, but it does not forward that authorization into `anthropics/claude-code-action`. Add a focused workflow contract test first, then pass the already job-authorized pull request author to the action's `allowed_non_write_users` input. +The fork review job already authorizes trusted contributors at its job-level condition and forwards that authorization into `anthropics/claude-code-action`. Extend the workflow contract so the same allowlist also labels newly opened fork PRs with `safe-to-review` and `safe-to-test`, while the preview workflow recognizes the allowlist directly because label events emitted by `GITHUB_TOKEN` do not start another workflow run. Keep the change base-controlled and limited to existing fork trust gates. ## Confirmed root cause @@ -18,22 +18,38 @@ PR #2072 is a cross-repository pull request authored and synchronized by `ClemDN - Add `.github/scripts/claude-code-review-workflow-contract_test.py` to assert that the fork job forwards its approved pull request author to the action's `allowed_non_write_users` input. - Update `.github/workflows/claude-code-review.yml` to pass `github.event.pull_request.user.login` to the fork action. -- Update `.github/workflows/lint-action-pinning.yml` so workflow changes run the new contract test in CI. +- Update `.github/workflows/claude-code-review.yml` with a base-controlled, open-only labeling job that adds both approval labels for allowlisted fork authors. +- Update `.github/workflows/preview-env.yml` so `CLAUDE_REVIEW_ALLOWLIST` is a direct trusted fork-preview gate for all existing non-closed pull-request-target events. +- Add focused contract coverage for the labeling job and preview gate, and run both contract tests from `.github/workflows/lint-action-pinning.yml`. ## Tests - **What:** An approved non-write fork author reaches Claude's internal permission bypass without evaluating the optional JSON allowlist again in the label-only path. - **File:** `.github/scripts/claude-code-review-workflow-contract_test.py` - **How:** A focused Python contract test reads the fork job and requires the exact `allowed_non_write_users` expression. It must fail before the workflow change and pass afterward. +- **What:** An allowlisted fork PR receives both trust labels from a base-controlled job only when it opens and is external to the repository. +- **File:** `.github/scripts/claude-code-review-workflow-contract_test.py` +- **How:** Assert the job gate, `issues: write` permission, and the `issues.addLabels` call with exactly `safe-to-review` and `safe-to-test`. +- **What:** The preview workflow authorizes allowlisted fork PRs without depending on a label event emitted by `GITHUB_TOKEN`. +- **File:** `.github/scripts/preview-env-workflow-contract_test.py` +- **How:** Assert the `deploy-fork` condition retains the existing `safe-to-test` and `PREVIEW_ENV_ALLOWLIST` paths and adds the `CLAUDE_REVIEW_ALLOWLIST` path. - **Commands:** - `python3 .github/scripts/claude-code-review-workflow-contract_test.py` + - `python3 .github/scripts/preview-env-workflow-contract_test.py` - `python3 .github/scripts/lint-action-pinning.py` ## Implementation - [x] [Task 01: Forward the approved fork allowlist](task-01-forward-fork-allowlist.md) +- [x] [Task 02: Label allowlisted fork pull requests](task-02-label-allowlisted-fork-prs.md) ## Risks - GitHub expression syntax is runtime-specific. The contract test pins the expected expression, while final proof still requires a new fork workflow run after the change reaches the base branch. - Re-evaluating the JSON variable in the action input could break the independent label-only path when that optional variable is empty. Passing the actor already approved by the job gate avoids that dependency. +- `CLAUDE_REVIEW_ALLOWLIST` becomes a trusted preview-execution boundary as well as a review boundary; maintainers must treat its entries as authorized to run fork preview code with deployment credentials. +- The label job uses the repository `GITHUB_TOKEN`, so labels are status markers and must not be the only trigger for privileged downstream workflows. + +## Verification Results + +Task 01 results are retained in `task-01-forward-fork-allowlist.md`. Task 02 results are recorded in `task-02-label-allowlisted-fork-prs.md`: both workflow contract suites passed, all 18 workflow action references are SHA-pinned, and `git diff --check` passed. Live verification against a real allowlisted fork PR remains post-merge work. diff --git a/docs/plans/claude-fork-review-allowlist/task-02-label-allowlisted-fork-prs.md b/docs/plans/claude-fork-review-allowlist/task-02-label-allowlisted-fork-prs.md new file mode 100644 index 0000000000..66a3992a3f --- /dev/null +++ b/docs/plans/claude-fork-review-allowlist/task-02-label-allowlisted-fork-prs.md @@ -0,0 +1,71 @@ +--- +id: "02-label-allowlisted-fork-prs" +title: "Label allowlisted fork pull requests" +status: done +wave: 1 +depends_on: ["01-forward-fork-allowlist"] +plan: "plan.md" +spec: "../../specs/claude-fork-review-allowlist/spec.md" +--- + +# Task 02: Label allowlisted fork pull requests + +## Acceptance + +- When an allowlisted user opens a fork pull request, a base-controlled job adds `safe-to-review` and `safe-to-test` with one idempotent label API call; same-repository and non-allowlisted pull requests do not enter that job. +- The Claude fork review path remains directly gated by `CLAUDE_REVIEW_ALLOWLIST`, and the preview fork deployment path also accepts that allowlist for its existing non-closed events, so the automation does not depend on a `GITHUB_TOKEN`-emitted `labeled` event to start another workflow. +- Contract tests cover the new job, labels, permissions, preview gate, and action pinning CI coverage. + +## Verification + +- `python3 .github/scripts/claude-code-review-workflow-contract_test.py` +- `python3 .github/scripts/preview-env-workflow-contract_test.py` +- `python3 .github/scripts/lint-action-pinning.py` +- `git diff --check` + +## Files likely touched + +- `.github/workflows/claude-code-review.yml` +- `.github/workflows/preview-env.yml` +- `.github/workflows/lint-action-pinning.yml` +- `.github/scripts/claude-code-review-workflow-contract_test.py` +- `.github/scripts/preview-env-workflow-contract_test.py` +- `docs/specs/claude-fork-review-allowlist/spec.md` +- `docs/plans/claude-fork-review-allowlist/plan.md` +- `docs/decisions/2026-08-07-claude-allowlist-label-bridge.md` +- `docs/decisions/INDEX.md` + +## Dependencies + +Task 01 is complete; use the existing `allowed_non_write_users` forwarding contract and preserve its workflow behavior. + +## Parallelism + +`sequential`; the workflow gates, contract tests, CI registration, and security documentation are one coupled trust-boundary change. + +## Inputs + +- `docs/specs/claude-fork-review-allowlist/spec.md`, especially the permissions, failure modes, and new label/preview scenarios. +- `docs/plans/claude-fork-review-allowlist/plan.md`. +- `docs/decisions/2026-08-07-claude-allowlist-label-bridge.md`. +- Existing `safe-to-review` and `safe-to-test` gates in `.github/workflows/claude-code-review.yml`, `.github/workflows/opencode-code-review.yml`, and `.github/workflows/preview-env.yml`. + +## Risks + +- `CLAUDE_REVIEW_ALLOWLIST` now authorizes the preview workflow to execute fork preview code with `SPRITES_API_TOKEN`; do not broaden the condition beyond the exact external-PR allowlist gate. +- Labels added with `GITHUB_TOKEN` do not recursively trigger a new workflow run, so removing the direct preview allowlist branch would silently break the requested automation. +- The repository must already contain both labels; a missing label should fail visibly rather than silently inventing a trust marker. + +## Output contract + +Report the exact workflow conditions and label API call, files changed, RED and GREEN contract-test results, action-pinning result, `git diff --check` result, any live-fork verification still needed after merge, and synchronized task/plan statuses. + +## Results + +- RED: `python3 .github/scripts/claude-code-review-workflow-contract_test.py` failed with 2 expected missing-contract assertions (label job and CI registration); `python3 .github/scripts/preview-env-workflow-contract_test.py` failed with the expected missing `CLAUDE_REVIEW_ALLOWLIST` preview gate. +- GREEN: `python3 .github/scripts/claude-code-review-workflow-contract_test.py` — 9 tests passed. +- GREEN: `python3 .github/scripts/preview-env-workflow-contract_test.py` — 1 test passed. +- GREEN: `python3 .github/scripts/lint-action-pinning.py` — all 18 workflow files use SHA-pinned action refs. +- GREEN: `git diff --check` — no whitespace errors. +- Files changed: `.github/workflows/claude-code-review.yml`, `.github/workflows/preview-env.yml`, `.github/workflows/lint-action-pinning.yml`, both workflow contract tests, and the linked spec/plan/ADR records. +- No live fork workflow or GitHub label API verification was performed locally; that remains a post-merge validation of repository configuration and the trusted allowlist. diff --git a/docs/specs/claude-fork-review-allowlist/spec.md b/docs/specs/claude-fork-review-allowlist/spec.md index c9d2beeb42..74974bfddf 100644 --- a/docs/specs/claude-fork-review-allowlist/spec.md +++ b/docs/specs/claude-fork-review-allowlist/spec.md @@ -6,17 +6,22 @@ owner: kdlbs # Claude Fork Review Allowlist -Decision: ADR-2026-07-31-isolate-manual-pr-review-content +Decision: ADR-2026-07-31-isolate-manual-pr-review-content; ADR-2026-08-07-claude-allowlist-label-bridge ## Why Trusted fork contributors listed in the repository's Claude review allowlist should receive an automatic review when they open a pull request, without requiring a maintainer to label it. Further review rounds should be explicit so pushes do not repeatedly consume Claude tokens. The workflow must pass the already-authorized contributor to Claude's separate non-write-user permission gate. +The same trusted contributors should also receive the repository's existing review and preview approval markers, so maintainers do not need to maintain a second identity list or apply two labels manually. + ## What - A fork pull request actor listed in `CLAUDE_REVIEW_ALLOWLIST` receives one automatic review when they open a pull request and passes both the workflow job gate and the Claude action's non-write-user gate. - `CLAUDE_REVIEW_ALLOWLIST` remains a JSON array for safe use in GitHub Actions expressions. - The Claude action receives the already job-authorized pull request author through its `allowed_non_write_users` input. +- When an allowlisted fork pull request opens, the base-controlled workflow adds both existing labels, `safe-to-review` and `safe-to-test`, to the pull request. The label operation is idempotent and applies only to fork pull requests whose opening author matches the allowlist. +- The `CLAUDE_REVIEW_ALLOWLIST` gate remains a direct authorization path for the fork review job, so adding labels with the repository `GITHUB_TOKEN` does not create a second review run through the `labeled` event. +- The preview workflow treats an author in `CLAUDE_REVIEW_ALLOWLIST` as trusted for fork preview deployment on every non-closed pull-request-target event it already handles (`opened`, `synchronize`, `reopened`, and `labeled`). This keeps preview deployment automatic even though the existing per-commit `safe-to-test` cleanup removes that label after a fork push. - A maintainer may apply `safe-to-review` to request the initial review of an untrusted fork pull request. - Pushes, ready-for-review transitions, and reopenings do not automatically start another Claude review. A maintainer can request a later review by commenting `@claude review` on the pull request. That requested review reads the current pull request head, including files newly added by the pull request. - Manual pull request reviews keep the trusted default branch at the workflow root and do not check out pull request content. Claude may use read-only local tools for trusted surrounding code, reads the current diff through constrained GitHub commands, and reads complete current-head PR files only through a path-validated, size-limited GET helper bound to the event's PR number. Its only write capability is posting review comments. @@ -24,11 +29,26 @@ Trusted fork contributors listed in the repository's Claude review allowlist sho - The same-repository review path remains unchanged except for the open-only trigger policy. - Empty, malformed, or non-matching allowlists continue to fail closed at the workflow job gate. +## Permissions + +- Only the base-controlled `pull_request_target` workflow may add the two labels. It does not check out or execute pull-request content for the labeling step. +- A matching `CLAUDE_REVIEW_ALLOWLIST` entry authorizes the existing fork review path and the preview workflow's fork deployment path, which has access to the preview deployment credentials. Repository maintainers are responsible for keeping this variable restricted to trusted contributors. +- A non-allowlisted fork author still needs the existing maintainer-applied labels; the new automation does not broaden the manual label paths. + +## Failure modes + +- An empty, malformed, or non-matching `CLAUDE_REVIEW_ALLOWLIST` produces no automatic labels and does not authorize the review or preview jobs. +- If the label API call fails or either repository label does not exist, the labeling job fails visibly. The direct review and preview gates still evaluate the allowlist independently; a label-write failure does not turn an untrusted author into a trusted one. +- Labels added with `GITHUB_TOKEN` do not themselves launch a new workflow run. The review and preview workflows therefore must retain their direct allowlist gates rather than depending only on the resulting `labeled` event. + ## Scenarios - **GIVEN** `CLAUDE_REVIEW_ALLOWLIST` is `["ClemDNL"]`, **WHEN** `ClemDNL` opens a fork pull request, **THEN** the fork review job supplies `ClemDNL` through `allowed_non_write_users` and Claude does not reject the run solely because the actor has repository permission `read`. +- **GIVEN** `CLAUDE_REVIEW_ALLOWLIST` is `["ClemDNL"]`, **WHEN** `ClemDNL` opens a fork pull request, **THEN** the pull request receives both `safe-to-review` and `safe-to-test` labels without a maintainer action. +- **GIVEN** an allowlisted fork pull request has a new commit pushed, **WHEN** the preview workflow handles the `synchronize` event, **THEN** the preview deploy job remains eligible through `CLAUDE_REVIEW_ALLOWLIST` even if the existing cleanup removes `safe-to-test`. - **GIVEN** a fork pull request has received its initial review, **WHEN** its contributor pushes another commit, marks it ready for review, or reopens it, **THEN** the Claude review workflow does not run again. - **GIVEN** a fork contributor is absent from `CLAUDE_REVIEW_ALLOWLIST`, **WHEN** they open or update their pull request without `safe-to-review`, **THEN** the fork review job does not run. +- **GIVEN** a fork contributor is absent from `CLAUDE_REVIEW_ALLOWLIST`, **WHEN** they open a pull request, **THEN** the automatic labeling job adds neither approval label and the preview workflow does not deploy unless its existing `PREVIEW_ENV_ALLOWLIST` or `safe-to-test` path authorizes it. - **GIVEN** a maintainer applies `safe-to-review` to a fork pull request, **WHEN** the labeled event runs, **THEN** the existing maintainer-approved review path remains available. - **GIVEN** a maintainer wants another review round, **WHEN** they comment `@claude review` on the pull request, **THEN** the existing Claude mention workflow recognizes the `@claude` mention and starts the requested review. - **GIVEN** a pull request adds a file after its initial review, **WHEN** a maintainer comments `@claude review`, **THEN** Claude can read and review that added file rather than ending without a review because the file is absent from the default-branch checkout. @@ -40,5 +60,7 @@ Trusted fork contributors listed in the repository's Claude review allowlist sho - Changing the pinned Claude Code Action version. - Changing the Claude OAuth token, GitHub token, or OIDC strategy. +- Creating a second preview-specific allowlist or removing `PREVIEW_ENV_ALLOWLIST`. +- Re-adding approval labels after every fork push or using a new personal access token/GitHub App solely to make label events recurse into other workflows. - Changing the OpenCode review workflow. - Changing the automatic review behavior for same-repository pull requests.