Plan: run CI for fork and federated pull requests, safely; pull_request_target
Status: ready to execute once
workflow-job-token.md is executed (this needs its
permissions claim to issue a read-only token). Written to be carried out
start to finish by one agent without further questions.
Why
Workflows.dispatch_pull_request/3 deliberately starts nothing for a
pull request whose head lives in another repository
(PullRequest.cross_repo?/1) or came in over federation
(PullRequest.federated?/1), and logs why at debug level. The reasons
were, and still are:
- the head is code the target’s maintainers haven’t reviewed, and the workflow files are read from that head — so the PR author writes the workflow;
- a job is handed the target repository’s secrets and variables
(
RunnerController.build_task/1→GitGud.Secrets.resolve_for(repo)) and a job token that can publish its packages; - the job runs on the target’s (self-hosted) runners.
So fork contributors get no CI at all, which is the main thing CI is for
on an open project. GitHub’s and Forgejo’s answer is: run fork PRs, but
without secrets, with a read-only token, and only after a maintainer
approves (for first-time contributors on GitHub; for any fork PR from
someone without write access on Forgejo). Plus pull_request_target,
which runs the target’s workflow with full rights and is how labelers
and commenters work on fork PRs.
Semantics to match
- Who is a fork run: a
pull_requestrun for a PR withcross_repo?orfederated?true. - What it gets:
- workflows from the PR head (as for same-repo PRs);
secrets: none exceptGITHUB_TOKEN/GITEA_TOKEN/FORGEJO_TOKEN(= the job token);vars: none (gitgud choice — variables often hold internal hostnames; stricter than GitHub, document it); the built-inCI_FORGE_URL/CI_REGISTRYvars stay (they’re public facts);- a job token with permissions forced to
contents: read, packages: read, actions: nonewhatever the workflow asks for (GitHub: “the GITHUB_TOKEN has read-only permissions in pull requests from forked repositories”); - cache: may restore from the PR’s ref and the base branch, may save
only under its own
refs/pull/N/headscope (already true: saves go to the run’s ref) — so it can’t poison the base branch’s cache.
- Approval (Forgejo’s rule, stricter than GitHub’s first-timer rule):
a fork run is created as waiting for approval unless the PR author is
a local user with write access to the target. Federated PRs always wait.
Someone with write access approves on the PR page; approval releases
every waiting run of that PR’s current head; a new push
(
synchronize) needs approval again unless the author has write access. Rejecting = cancelling the runs. pull_request_target:on: pull_request_targetwith the sametypes/branchesfilters aspull_request. Runs the workflow from the target branch’s head, at that commit (github.sha= target head,github.ref=refs/heads/<target>), with the target’s secrets and a normal token — for every PR, same-repo or fork, because the code that runs is the target’s own. The PR is ingithub.event.pull_request(head sha, head repo clone URL). GitHub runs it without approval; do the same, and say loudly in the docs that checking out the PR head inside apull_request_targetjob is the classic way to leak secrets.
Where things are
| Dispatch | lib/git_gud/workflows.ex dispatch_pull_request/3, do_dispatch_pull_request/3, create_matching_runs/5, create_run/2, create_job_leg/3 |
| PR heads | lib/git_gud/pull_requests.ex pin_pull_head/4 (fork head pinned into the target as refs/pull/<N>/head at creation), refresh_cross_repo_heads/4 (fetches the fork’s new head into that ref on push, enqueues synchronize), create_federated_pull_request/3, refresh_federated_head/2 |
| PR worker | lib/git_gud/workflows/workers/pull_request_dispatch.ex |
| Task | lib/git_gud_web/controllers/runner_controller.ex build_task/1, builtin_vars/0 |
| Token | lib/git_gud/workflows/runner_jwt.ex (after the job-token plan: mint/3 with perms), GitGud.Workflows.Permissions |
| Claiming | Workflows.do_claim_next_job/2 (claims queued only) |
| Permissions | GitGud.Organizations.can?/3 (:write) |
| PR page | lib/git_gud_web/live/pr_live/show.ex |
| Tests | test/git_gud/workflows/pull_request_dispatch_test.exs (on-disk PRs, Oban.drain_queue), test/git_gud/forks_test.exs (Repositories.fork/4 to make a real fork), test/git_gud/pull_requests_federated_test.exs (federated PR fixtures) |
Steps
1. Mark fork runs
Migration (mix ecto.gen.migration add_fork_fields_to_workflow_runs):
workflow_runs.fork :boolean, default false, null false,
workflow_runs.approved_by_id (FK users, nullable), and
approved_at :utc_datetime. Add job status awaiting_approval (not
claimable, not terminal) to WorkflowJob, the UI badges, and
maybe_complete_run/1’s in-flight set.
2. Dispatch forks
In dispatch_pull_request/3, replace the refusal for
cross_repo?/federated? with: proceed, fork: true. Workflows are read
from pr.head_sha, which is in the target’s object database because
pin_pull_head/4 / refresh_cross_repo_heads/4 / the federated fetch put
it under refs/pull/<N>/head — verify for all three paths and add a test
for each. do_dispatch_pull_request/3 already pins the ref.
Approval: compute needs_approval? = fork and not (local author with
Organizations.can?(author, repo, :write)). When true, every job that
would be queued is created awaiting_approval instead (thread an option
through create_matching_runs/5 → create_run/2 → create_job_leg/3);
jobs that would be blocked stay blocked (they’re released through
resolve_dependents/1 only after their needs ran, which needed approval).
3. Approve / reject
Workflows.approve_pull_request_runs(pr, approver): for runs with
fork: true, head_sha == pr.head_sha, and any awaiting_approval job:
set approved_by_id/approved_at and move those jobs to queued.
Workflows.reject_pull_request_runs(pr, actor): cancel/1 them. Both
require can?(actor, repo, :write) (check in the function, return
{:error, :forbidden}). PR page: when the PR’s head has such runs and the
viewer may write, show “Workflows from this fork are waiting for
approval” with Approve and Reject buttons (DOM ids
approve-fork-runs, reject-fork-runs); for others, a plain notice.
4. Strip what a fork run gets
In build_task/1, when run.fork:
secrets= only the three token names (after the context plan adds them; if it hasn’t been executed,%{});vars=builtin_vars()only;- mint the token with permissions
%{"contents" => "read", "packages" => "read", "actions" => "none"}, ignoring the workflow’spermissions:. Assert in a test thatGitGud.Secrets.resolve_for/1is not called for a fork run (e.g. a repo secret exists and isn’t in the task).
5. pull_request_target
Workflows.dispatch_pull_request_target(pr, action, opts): resolve the target branch head; discover workflows there; matchYaml.matches?(doc, "pull_request_target", ctx)with the same ctx shape aspull_request(target branch, action, changed paths of the PR’s diff) — extendYaml.type_match?/3andref_match?/4to treatpull_request_targetlikepull_request. Create runs withtrigger: "pull_request_target",head_sha= target head,head_ref=refs/heads/<target>,fork: false(full rights), PR fields set (pull_request_number,pr_head_ref,pr_base_ref).- Call it from the same places
pull_requestis dispatched (PullRequestDispatch.perform/1→ dispatch both triggers), for all PRs including forks and federated ones, and with no approval gate. - Runner context: for
pull_request_targetruns,ref/ref_name/ref_typeare the target branch’s (notrefs/pull/N/head),head_refandbase_refas forpull_request; event payload as forpull_request(if the context plan’s payload exists).
6. Docs
README CI list: fork and federated PRs run after approval, without
secrets or variables and with a read-only token; pull_request_target
semantics and its warning. Remove “fork and federated pull requests don’t
run yet” from README and from the dispatch_pull_request/3 doc and log
line.
Tests
On disk (pull_request_dispatch_test.exs style, with a real fork from
Repositories.fork/4):
- fork PR from a user without write access → run created, jobs
awaiting_approval, nothing claimable (Workflows.claim_next_job/1returns nil for a matching runner); - approve → jobs queued; a later push to the fork → new run awaiting approval again;
- fork PR from a user with write access → queued immediately;
- reject → runs cancelled;
- the fork run’s task (FetchTask through the controller): no repo
secrets, no repo vars,
GITHUB_TOKENpresent, token claimscontents: read, packages: read; publishing a package with it fails; - federated PR → awaiting approval (use the federated fixtures);
pull_request_targetworkflow present only on the target branch runs for a fork PR, at the target head, with secrets; apull_request_targetworkflow added by the PR (only on its head) does not run;- same-repo PRs unchanged (regression: existing tests pass).
Done when
mix testpasses; no new compile warnings (comparemix compile --forcewarning lists before/after);mix formatonly touched files.- Two commits in the repository’s message style: fork runs with approval,
then
pull_request_target. - Not pushed.