9.6 KiB · text History 876aa0b

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_request run for a PR with cross_repo? or federated? true.
  • What it gets:
    • workflows from the PR head (as for same-repo PRs);
    • secrets: none except GITHUB_TOKEN/GITEA_TOKEN/FORGEJO_TOKEN (= the job token); vars: none (gitgud choice — variables often hold internal hostnames; stricter than GitHub, document it); the built-in CI_FORGE_URL / CI_REGISTRY vars stay (they’re public facts);
    • a job token with permissions forced to contents: read, packages: read, actions: none whatever 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/head scope (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_target with the same types/branches filters as pull_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 in github.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 a pull_request_target job 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’s permissions:. Assert in a test that GitGud.Secrets.resolve_for/1 is 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; match Yaml.matches?(doc, "pull_request_target", ctx) with the same ctx shape as pull_request (target branch, action, changed paths of the PR’s diff) — extend Yaml.type_match?/3 and ref_match?/4 to treat pull_request_target like pull_request. Create runs with trigger: "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_request is 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_target runs, ref/ref_name/ ref_type are the target branch’s (not refs/pull/N/head), head_ref and base_ref as for pull_request; event payload as for pull_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/1 returns 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_TOKEN present, token claims contents: read, packages: read; publishing a package with it fails;
  • federated PR → awaiting approval (use the federated fixtures);
  • pull_request_target workflow present only on the target branch runs for a fork PR, at the target head, with secrets; a pull_request_target workflow added by the PR (only on its head) does not run;
  • same-repo PRs unchanged (regression: existing tests pass).

Done when

  • mix test passes; no new compile warnings (compare mix compile --force warning lists before/after); mix format only touched files.
  • Two commits in the repository’s message style: fork runs with approval, then pull_request_target.
  • Not pushed.