ci: enable CI for fork PRs via /run-ci+[full-head-sha] comment - #13578
ci: enable CI for fork PRs via /run-ci+[full-head-sha] comment#13578rodrigosf672 wants to merge 5 commits into
/run-ci+[full-head-sha] comment#13578Conversation
Switch test-pull-request.yml from `pull_request` to `pull_request_target` with a label-based authorization gate. Internal PRs run immediately; fork PRs require a maintainer to add the `run-ci` label before CI triggers. The label persists across pushes so subsequent commits auto-trigger CI. Add `commit` input to reusable workflows that lacked it so the correct fork SHA is checked out. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
E2E Tests 🚀 |
run-ci label gate/run-ci comment
# Conflicts: # .github/workflows/test-pull-request.yml
Replace the label-based gate with a comment-triggered flow: - ci-approve-fork.yml: /run-ci comment triggers team membership check against posit-dev/positron-dev, then adds run-ci label - ci-revoke-fork.yml: auto-removes label on new fork pushes, forcing re-approval per commit - authorize gate: forks can only enter CI via a fresh labeled event (immune to stale-label races on synchronize) - unlabeled event: manual label removal cancels in-flight CI
927e66b to
3357612
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces a maintainer-mediated approval gate that lets CI run on fork-based PRs only after a trusted reviewer comments /run-ci, using pull_request_target + a run-ci label + automatic revocation on new commits.
Changes:
- Switches PR CI workflow to
pull_request_targetand adds anauthorizegate that only permits fork PR CI runs via a freshlabeled: run-cievent. - Adds a comment-triggered approval workflow that verifies team membership and applies the
run-cilabel (plus 🚀 reaction). - Adds a revocation workflow that removes
run-cion new pushes to fork PRs and posts a notification comment.
Review Findings (blocking)
.github/workflows/test-pull-request.yml:authorizeuses- run: true, butrun:must be a string. This will fail workflow validation. Replace with a no-op command likerun: echo "authorized"(or quote it asrun: "true").- Reusable workflows commit checkout may fail under
pull_request_target: In the reusable workflows you addedgit checkout ${{ inputs.commit }}afteractions/checkout, but underpull_request_targetthe default ref is the base branch, so that commit may not be present locally. Prefer checking out the requested SHA directly inactions/checkout(e.g.with: ref: ${{ inputs.commit }}) or explicitly fetching the SHA beforegit checkout. .github/workflows/ci-revoke-fork.yml: The workflow posts a PR comment viagh pr comment, but only requestspull-requests: write. Creating PR comments generally requiresissues: write(PR comments are issue comments), so the “Post comment” step is likely to fail without addingpermissions: issues: write.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| .github/workflows/test-pull-request.yml | Moves CI to pull_request_target, adds authorize gate, and propagates head SHA to downstream reusable workflows. |
| .github/workflows/test-e2e-workbench-ubuntu.yml | Adds optional commit input and attempts to checkout a specific SHA for workbench E2E. |
| .github/workflows/test-e2e-ubuntu.yml | Adds optional commit input and attempts to checkout a specific SHA for Ubuntu E2E. |
| .github/workflows/test-e2e-pyrefly.yml | Adds optional commit input and attempts to checkout a specific SHA for Pyrefly E2E. |
| .github/workflows/test-e2e-jupyter-ubuntu.yml | Adds optional commit input and attempts to checkout a specific SHA for Jupyter E2E. |
| .github/workflows/ci-revoke-fork.yml | New workflow to revoke run-ci label on fork PR pushes and notify maintainers. |
| .github/workflows/ci-approve-fork.yml | New workflow to authorize /run-ci comments by team members and apply the run-ci label. |
| .github/workflows/build-workbench-linux.yml | Adds optional commit input and attempts to checkout a specific SHA for workbench builds. |
| .github/workflows/build-jupyter-linux.yml | Adds optional commit input and attempts to checkout a specific SHA for server builds. |
Under pull_request_target, actions/checkout fetches only base-repo refs. A post-checkout `git checkout <sha>` fails for fork commits because the SHA is never fetched. Pass the commit directly via the `ref:` input so actions/checkout fetches the correct ref from the fork.
Finding 1 (
|
Address security-review concerns on the fork PR CI gate: - ci-approve-fork: require an exact full 40-char head SHA in /run-ci so approval can never drift to an unreviewed commit (closes the review-vs-run TOCTOU). Reject bare and short SHAs with actionable feedback. Anchor the command to its own line so prose mentions never approve. Read the comment body via env (no shell injection). Post an audit comment naming the approved commit and approver. - All fork-code checkouts: persist-credentials: false so the write-scoped pull_request_target GITHUB_TOKEN is not left in .git/config for fork build scripts to reuse.
/run-ci comment/run-ci+full-head-sha comment
/run-ci+full-head-sha comment/run-ci+[full-head-sha] comment
|
Note: This PR is still active and under review/work; it is not stale. |
|
We're going to close this out now after the security review; see #6628 (comment) for more details. We'll keep an eye on options we might have for improving this in the future. |
[This should NOT be merged without security review]
Summary
Closes #6628
External contributors opening PRs from forks currently see a wall of CI failures because workflows can't access repo secrets. This PR fixes that with a comment-triggered, per-commit approval gate:
/run-ci+full SHAfor the commit that matches head.@posit-dev/positron-devand adds therun-cilabelInternal PRs continue to run CI immediately with no change in behavior.
Happy path
Internal PRs run CI immediately, as before. Fork PRs are blocked until a
@posit-dev/positron-devmember reviews the diff and approves a specific commit with/run-ci <sha>— the approval is pinned to that exact commit and is revoked automatically on any new push. (Any team member counts as a maintainer for this gate; the two are treated as one and the same.)flowchart TD A[Fork PR opened / new push] --> B{Is it a fork?} B -- No --> C[CI runs immediately] B -- Yes --> D[CI blocked — no run-ci label] D --> E[Maintainer reviews diff] E --> F["Commenter posts /run-ci + SHA"] F --> G{"Commenter ∈ positron-dev (= maintainer)?"} G -- No --> H[Rejected; no action] G -- Yes --> I["Bot adds run-ci label + 🚀 reaction"] I --> J["labeled event → CI runs for HEAD SHA"] J --> K[Fork author pushes new commits] K --> L["synchronize event → label auto-removed"] L --> M["Bot comments: approval revoked, /run-ci to re-run"] M --> DFailure scenarios
1. Revoke workflow fails (label persists after push)
This is extremely unlikely, but if the revoke workflow fails and the
run-cilabel is not auto-removed after a push, CI still won't run. The authorize gate checks the event type, not the label state. Hence, a synchronize event from a fork is rejected regardless of what labels are present. Only a fresh labeled event (triggered by/run-ciby a maintainer) can pass the gate. To recover, a maintainer removes the stale label, reviews the new diff, and comments/run-ci.flowchart TD A[Fork author pushes new commits] --> B["synchronize event fires"] B --> C["ci-revoke-fork.yml fails ❌"] C --> D["run-ci label remains on PR"] D --> E["test-pull-request.yml fires on synchronize"] E --> F{"authorize gate checks event action"} F -->|"action == 'synchronize' ≠ 'labeled'"| G["Gate BLOCKED; CI does not run ✅"] G --> H["Maintainer notices stale label"] H --> I["Removes label, reviews diff, comments /run-ci"]2. Approve workflow fails (label never added)
If the approve workflow fails (e.g. expired PAT, GitHub API outage), the
run-cilabel is never added, so no labeled event fires. Without that event, the authorize gate never evaluates for the fork PR, hence CI simply doesn't start. The maintainer sees the failed workflow run in the Actions tab, investigates/resolves the issue, and comments/run-ciagain.flowchart TD A["Commenter posts /run-ci"] --> B["ci-approve-fork.yml fires"] B --> C{"Team membership check (∈ positron-dev = maintainer)"} C -->|"API error / PAT expired"| D["Workflow fails ❌"] D --> E["No label added, no labeled event"] E --> F["CI never triggers; safe default ✅"] F --> G["Maintainer sees failed workflow run"] G --> H["Fixes PAT / retries /run-ci"]3. Manual label removal (cancel in-flight CI)
If a maintainer removes the
run-cilabel while CI is running (e.g. they spot something suspicious in the diff mid-run), the unlabeled event fires and starts a new workflow run in the same concurrency group. Becausecancel-in-progress: trueis set, this new run cancels the in-flight CI immediately. The new run then hits theauthorizegate, which rejects unlabeled events unconditionally, so it exits without doing anything. The PR returns to a blocked state.flowchart TD A["CI running for fork PR"] --> B["Maintainer removes run-ci label"] B --> C["unlabeled event → new workflow run starts"] C --> D["Same concurrency group → cancel-in-progress: true"] D --> E["In-flight CI cancelled ✅"] E --> F["New run hits authorize gate"] F --> G["action == 'unlabeled' → gate fails immediately"] G --> H["PR returns to blocked state"]4. New commit pushed after approval (per-commit revocation + SHA pin)
After a maintainer approves commit
A(label added, CI triggered forA), the fork author pushes commitB. Thesynchronizeevent firesci-revoke-fork.yml, which strips therun-cilabel and comments that approval is revoked. Independently, the CI that was triggered by thelabeledevent is pinned to commitA— thelabeledevent payload carries the head SHA at label time — so it never tests the unreviewedBeven if it is still running. RunningBrequires a fresh/run-ci <B-sha>after re-review.flowchart TD A["Maintainer approves commit A → run-ci label added"] --> B["CI triggered for commit A (pinned by labeled event payload)"] B --> C["Fork author pushes commit B"] C --> D["synchronize event → ci-revoke-fork.yml"] D --> E["run-ci label removed + revoke comment posted"] E --> F["Unreviewed commit B never runs CI ✅"] F --> G["Maintainer re-reviews, comments /run-ci B-sha"]5. Maintainer approves a stale commit (push before approval — TOCTOU)
A maintainer reviews commit
A, but the fork author pushes commitBbefore the maintainer comments. Still looking at the old diff, the maintainer comments/run-ci <A-sha>.ci-approve-fork.ymlresolves the PR's current head (B) and compares it to the requested SHA (A); because they differ, it rejects the approval, posts a comment showing the new head, and adds no label. CI never runs the unreviewedB. The maintainer must re-review and comment/run-ci <B-sha>. (A bare/run-ci, or a short/partial SHA, is rejected the same way — only an exact full 40-char head SHA approves.)flowchart TD A["Maintainer reviews commit A"] --> B["Fork author pushes commit B (new head)"] B --> C["Maintainer comments /run-ci A-sha"] C --> D["ci-approve-fork resolves current head = B"] D --> E{"requested A == head B?"} E -->|"No — stale / mismatched SHA"| F["Approval rejected; no label added ✅"] F --> G["Bot comments new head; maintainer re-reviews"] G --> H["Maintainer comments /run-ci B-sha"]Security model
pull_request_targetresolves YAML frommain, not the fork/run-civerified againstposit-dev/positron-devteam membershipsynchronize— re-approval required per commit/run-cirequires the full 40-char SHA the maintainer reviewed; approval is rejected unless it exactly matches the current head/run-ciyet" is ignored; the comment body is read via an env var, never interpolated into a shell scriptauthorizegate blocks all downstream jobs; secrets never flow without labelpull_request_targettoken reused by fork codepersist-credentials: falseon every checkout that pulls fork code, so the token is not left in.git/configauthorizeonly accepts fork PRs via thelabeledevent path, neversynchronizePrior discussion (from the issue)
pull_request_target+ environment gatespull_request_targetwith a protected environmentpull_request_targeteventsTest plan
This test plan requires PR to be merged, which can only happen after security approval. Do NOT merge without security approval.
/run-ci→ verify label is NOT added/run-ci→ verify label added, 🚀 reaction, CI triggers/run-ciagain → verify CI re-triggers for new SHArun-cilabel while CI is running → verify CI cancelledObservation: upon merged, these tests above will be conducted in three different GitHub accounts (part of positron-dev, part of posit-dev but NOT part of positron-dev, and external, NOT part of posit-dev).
Potential security questions
Please find below some security questions one could ask and the respective answers based on the approach planned to be adopted here.
1. Why
pull_request_targetinstead of GitHub's native "require approval for outside collaborators"?The native setting runs fork PRs as
pull_request, which has no access to repo secrets. Our e2e/build jobs need them (interpreters, licenses, OIDC), so we needpull_request_targetplus a manual gate.2. Can a fork change the gate itself (logic, triggers, permissions)?
No.
pull_request_targetresolves all workflow YAML from the base branch (main), so edits to.github/workflows/**in the fork are ignored for the gating run.3. Can a fork author approve their own PR?
No.
/run-ciis honored only if the commenter is an active member ofposit-dev/positron-dev, verified server-side. The check usescomment.user.login(GitHub-controlled), so it can't be spoofed via the comment body.4. Is the
/run-citrigger injectable?No. The command must be on its own line (prose mentions are ignored), and the attacker-controlled comment body is read via an env var, never interpolated into the shell script — so there's no command-injection surface.
5. Can the code that runs differ from the code that was reviewed (TOCTOU)?
No.
/run-cirequires the exact full 40-char head SHA; a mismatch (push-after-review) is rejected. Thelabeledevent payload pinshead.shaat label time, so the triggered run tests exactly the approved commit, and any later push auto-revokes the label.6. Why require the full SHA instead of a short one?
A 7-char short SHA is only a 28-bit prefix and is brute-forceable via commit grinding; the full 40-char SHA (160 bits) is not. Short/partial SHAs are rejected.