Actions: fork and federated pull requests run, after approval, without secrets

1b35c31 · Gabriel Morell · 2026-10-17 18:44

33 files +1075 -103
Message
{commit_body(@commit)}

Files changed

modified README.md
+3 −2
@@ -89,7 +89,8 @@ reinvent them.
89 89 - **Matrix builds**, with `runs-on` as a real label set — `strategy.matrix` expands into one job per combination, each claimed independently, so `runs-on: [self-hosted, "${{ matrix.arch }}"]` sends the arm64 leg to an arm64 machine and the amd64 leg to an amd64 one. `include`/`exclude` follow GitHub's semantics, `fail-fast` defaults on, `max-parallel` caps how many legs of the job run at once, and `needs:` names the group rather than a leg. The matrix, an axis, or `include`/`exclude` can be computed — `matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}` — with the expression subset below plus `fromJSON()`/`toJSON()`; one that reads `needs` shows as a single job until those needs finish, then becomes its legs. A matrix with no combinations left (everything excluded, an empty axis) or an expression that doesn't evaluate to the right shape fails the job, saying why in its log
90 90 - **Five deployment styles** offered in the registration UI: Native binary, Docker, Podman, Podman Quadlet, Kubernetes (Deployment + dind sidecar) — copy-pasteable `register` + `daemon` snippets per style
91 91 - **Triggers**: `push` (with `branches:` / `branches-ignore:` / `tags:` / `tags-ignore:`, GitHub's rule that naming one kind excludes the other kind of ref, and a list beating its `-ignore` if both are set), `pull_request` and `workflow_dispatch`
92 − - **`on: pull_request`** for pull requests within one repository: `types:` `opened`, `synchronize` (the head moved) and `reopened` (the default), and — when `types:` names them — `closed` (on close and on merge; `github.event.pull_request.merged` tells them apart, and it is dispatched once, as the PR closes), `edited` (title or body changed) and `labeled` / `unlabeled` (`github.event.label.name`). `branches:` / `branches-ignore:` filter the **target** branch, and `paths:` the PR's diff from its merge-base. Workflows are read from the PR's head commit, so a PR can change its own CI, and the job tests the head itself at `refs/pull/<N>/head` — not a merge with the target, as GitHub's `refs/pull/<N>/merge` does. The job gets `github.event_name`, `head_ref` and `base_ref` (the source and target branches), `ref_name` `<N>/head` and an empty `ref_type`. **Fork and federated pull requests don't run yet**: their head is code the target's maintainers haven't reviewed, and a job is handed the target repository's secrets and a token that can publish its packages; GitHub runs them without secrets and with a read-only token, and until that exists here they don't run at all. `pull_request_target` isn't supported
92 + - **`on: pull_request`** for pull requests within one repository: `types:` `opened`, `synchronize` (the head moved) and `reopened` (the default), and — when `types:` names them — `closed` (on close and on merge; `github.event.pull_request.merged` tells them apart, and it is dispatched once, as the PR closes), `edited` (title or body changed) and `labeled` / `unlabeled` (`github.event.label.name`). `branches:` / `branches-ignore:` filter the **target** branch, and `paths:` the PR's diff from its merge-base. Workflows are read from the PR's head commit, so a PR can change its own CI, and the job tests the head itself at `refs/pull/<N>/head` — not a merge with the target, as GitHub's `refs/pull/<N>/merge` does. The job gets `github.event_name`, `head_ref` and `base_ref` (the source and target branches), `ref_name` `<N>/head` and an empty `ref_type`.
93 + - **Fork and federated pull requests run, after approval**: their workflow comes from the PR's head, so their author wrote it, and their runs are marked as fork runs. A fork run's job gets **no secrets** but the job token, **no variables** but the built-in `CI_FORGE_URL` / `CI_REGISTRY` (stricter than GitHub, which still hands over variables: here they often hold internal hostnames — expressions the forge evaluates for it, `concurrency:` and matrices, can't read them either), and a **read-only token** (`contents: read`, `packages: read`, nothing else) whatever its `permissions:` say. Its jobs wait as *awaiting approval* — not claimable by any runner, and pending in the commit status — until someone with write access to the target approves them on the pull request page (Approve, or Reject, which cancels them); unless the PR's author is a local user with write access to the target, and so is whoever triggered the run (a fork pushed to by someone else needs approving). Federated pull requests always wait. Approval covers the runs on the PR's current head; every new push needs approving again (Forgejo's rule, stricter than GitHub's first-time-contributor one). A fork run's cache saves go to `refs/pull/<N>/head`, so it can't seed the base branch's cache. `github.event.pull_request.head.repo` is the fork (`fork: true`), or for a federated PR its clone URL, and a federated author's `user.login` is `name@host`. `pull_request_target` isn't supported
93 94 - **`on: workflow_dispatch`** from the Actions page ("Run workflow", for anyone who can write to the repository: pick a branch and fill in the inputs) or the Gitea/GitHub API (`POST /api/v1/repos/:owner/:name/actions/workflows/:file/dispatches`, a `ci:write` token or a job token). Declared `inputs:` are checked as GitHub checks them — an undeclared input, a missing `required` one, a `choice` outside its `options`, a `boolean` that isn't true/false or a `number` that isn't numeric answers 422 — and every declared input, defaults applied, reaches the job as `inputs.*` and `github.event.inputs.*` (strings)
94 95 - **`on: schedule`** (`- cron:`, five-field cron in UTC): read from the **default branch's** latest commit and run there, whichever branch the workflow file was pushed to; `github.event.schedule` is the cron that fired. Crons that can fire more often than **every five minutes** (GitHub's minimum), `@reboot` and ones that don't parse are ignored with a warning in the log. Archived repositories don't run their schedules
95 96 - **`on: create` / `on: delete`** when a branch or tag is created or deleted (`branches:` / `tags:` filters don't apply, as on GitHub). `create` runs the new ref's workflows at its commit, alongside its `push` run; `delete` runs the **default branch's** workflows at its head, since the ref is gone (nothing runs when the default branch itself is deleted). `github.event.ref` is the short name, `github.event.ref_type` `branch` or `tag`
@@ -101,7 +102,7 @@ reinvent them.
101 102 - **Jobs that depend on jobs**, as on GitHub: `${{ needs.<id>.outputs.* }}` and `needs.<id>.result` (`success`, `failure`, `cancelled`, `skipped`; for a matrix, the worst leg's, with the legs' outputs merged) reach the dependent job. A job waits until all its needs finish; if any didn't succeed it is **skipped** — and its own dependents with it — unless its `if:` calls `always()`, `failure()` or `cancelled()`, so cleanup and "report the failure" jobs run. A job its own `if:` skipped shows as skipped, not green. Job-level `continue-on-error: true` (or `${{ matrix.* }}` that comes out `true`) keeps a failure from skipping its dependents, failing the run or fail-fasting its matrix siblings, though the job still shows as failed; any other expression there counts as false. Skipped jobs don't fail a run. Unlike GitHub, **cancelling a run cancels its waiting jobs too**, `always()` or not
102 103 - Re-run / cancel buttons on the workflow show page. **Cancelling stops running jobs**: the runner hears it in the reply to its next progress report and stops the job, still uploading its log; a cancelled job stays cancelled whatever its runner reports afterwards
103 104 - **`concurrency:`**, workflow and job level, as a group name or `{group:, cancel-in-progress:}`: within a group (per repository) one run or job is in progress and at most one waits (shown as "Waiting for <group>"); a newer arrival cancels the waiting one, or with `cancel-in-progress` the one in progress too, and when the one in progress finishes the waiting one starts. A run (or job) cancelled only by a newer one in its group concludes `cancelled`, not failed. A workflow's group is decided when the run is created, a job's when it would be queued, so it can read `matrix` and `needs`. Group and `cancel-in-progress` may use `${{ }}` with the forge's own expression subset: property paths into `github`, `inputs`, `vars`, `matrix`, `needs` (`a.b`, `a['b']`), string/number/`true`/`false`/`null` literals, `||`, `&&`, `==`, `!=`, `!`, parentheses, `format()`, `fromJSON()` and `toJSON()`. A group outside that subset (any other function, say) is ignored with a warning, shown on the job — the run goes ahead **without** concurrency control rather than being held on a guess. Re-running a single job puts it back through its job-level group, not its run's
104 − - **A commit status per job**, named as Forgejo names them — `<workflow> / <job> (<event>)`, e.g. `ci / test (push)` or `ci / build (arm64, 28) (pull_request)` for a matrix leg — so a pull request's `push` run and `pull_request` run on the same head (or two jobs of one workflow) don't overwrite each other. Queued, blocked, waiting and running jobs are pending; success and skipped are success; failure and cancelled are failure, except a job cancelled by a newer one in its concurrency group, which shows as cancelled. Each pill links to its job on the run page, and the pull request page updates them live
105 + - **A commit status per job**, named as Forgejo names them — `<workflow> / <job> (<event>)`, e.g. `ci / test (push)` or `ci / build (arm64, 28) (pull_request)` for a matrix leg — so a pull request's `push` run and `pull_request` run on the same head (or two jobs of one workflow) don't overwrite each other. Queued, blocked, waiting, awaiting-approval and running jobs are pending; success and skipped are success; failure and cancelled are failure, except a job cancelled by a newer one in its concurrency group, which shows as cancelled. Each pill links to its job on the run page, and the pull request page updates them live
105 106 - **Required status checks** in branch protection: with "Require status checks to pass before merging", a pull request merges into the branch only when every required check (one per line; `*` matches anything, `/` and spaces included, so `ci / test *` covers both events) matches at least one status on its head and every status it matches is success. An empty list means every status on the head, and there must be one. The merge box lists what's missing or not passing and disables the merge; an admin's override merges past them, recorded like a review-rule override. Direct pushes aren't checked — there's no head to have checks on, as on Forgejo
106 107 - **The job token** (`${{ github.token }}`, an HS256 JWT minted per job on `FetchTask`) works only while its job runs and for five minutes after (for the runner's post-job cache save and artifact upload), and only for that attempt of the job — a leaked token no longer stays valid for a day. It clones and fetches its own repository, reads and publishes its packages, reads its metadata over the API, dispatches workflows (its own repository's, or one that granted it), and reaches the artifact + cache endpoints of its run. **`permissions:`** narrows it as on GitHub: workflow level, replaced by a job's own; `read-all`, `write-all`, `{}` or a map in which unnamed scopes are `none`. `contents` (`read` fetch, `write` also push — to its own repository, subject to branch protection like any direct push, attributed to whoever started the run), `packages` (`read` / `write`) and `actions` (`write` to dispatch) mean something; `metadata` is always read; the others (`issues`, `pull-requests`, `statuses`, `checks`, `deployments`, `id-token`, …) are accepted and ignored, since the token can't do those things here anyway. With no `permissions:` the token keeps `contents: read, packages: write, actions: write` — GitHub's permissive default would also let it push. **A push made with the job token starts no workflows** (no `push` run, no pull request `synchronize`), as on GitHub, so a workflow that pushes can't trigger itself forever; webhooks and pull request heads still update
107 108 - **GitHub Actions cache server v1**, scoped to the repository. Restore order: the primary key exactly, then each fallback key as a prefix (newest first), each tried on the run's own ref, then — for a `pull_request` run — the PR's base branch, then the default branch; a save goes to the run's own ref. Upload chunks are placed at their `Content-Range` offsets (`actions/cache` sends four at once) and assembled on finalize; a finalized entry is immutable. Age/size eviction worker, which also clears uploads abandoned for a day. **Not reached by a default forgejo-runner**: with `cache.enabled` (its default) the runner starts its own cache server and points `ACTIONS_CACHE_URL` at it, ignoring the URL gitgud sends; jobs only use this one if the runner has `cache.enabled: false` and `ACTIONS_CACHE_URL` set in `runner.envs`. `actions/cache@v4`'s cache service v2 isn't implemented
modified lib/git_gud/pull_requests.ex
+21 −5
@@ -321,6 +321,16 @@ defmodule GitGud.PullRequests do
321 321 Repo.rollback(cs)
322 322 end
323 323 end)
324 + |> case do
325 + # Its `pull_request` runs wait for a maintainer's approval
326 + # (`Workflows.dispatch_pull_request/3`).
327 + {:ok, pr} ->
328 + :ok = PullRequestDispatch.enqueue(pr, "opened", nil)
329 + {:ok, pr}
330 +
331 + error ->
332 + error
333 + end
324 334 end
325 335 end
326 336
@@ -389,9 +399,15 @@ defmodule GitGud.PullRequests do
389 399 pr.head_sha
390 400 )
391 401
392 − pr
393 − |> Ecto.Changeset.change(head_sha: head_sha, base_sha: base_sha, mergeable: nil)
394 − |> Repo.update()
402 + with {:ok, updated} <-
403 + pr
404 + |> Ecto.Changeset.change(head_sha: head_sha, base_sha: base_sha, mergeable: nil)
405 + |> Repo.update() do
406 + if pr.head_sha != head_sha,
407 + do: :ok = PullRequestDispatch.enqueue(updated, "synchronize", nil)
408 +
409 + {:ok, updated}
410 + end
395 411 end
396 412 end
397 413
@@ -519,8 +535,8 @@ defmodule GitGud.PullRequests do
519 535 commits: count_new_commits(source_repo, old_sha, new_sha)
520 536 })
521 537
522 − # `pull_request` workflows' `synchronize`. Fork PRs pass through
523 − # here too and are turned away by the dispatcher.
538 + # `pull_request` workflows' `synchronize`; for a fork PR, runs
539 + # that wait for approval unless the pusher may write to the target.
524 540 if Keyword.get(opts, :dispatch, true),
525 541 do: :ok = PullRequestDispatch.enqueue(updated, "synchronize", pusher_id)
526 542 end
modified lib/git_gud/workflows.ex
+235 −38
@@ -289,12 +289,15 @@ defmodule GitGud.Workflows do
289 289 `:payload` (merged into the event payload: `labeled`'s `"label"`).
290 290
291 291 Workflows are read from the PR's head commit, as on GitHub, which is what
292 − lets a pull request change its own CI. That is also why fork and
293 − federated pull requests are not dispatched at all: their head is code the
294 − target's maintainers haven't reviewed, and a job is handed the target
295 − repository's secrets and a token that can publish its packages. GitHub
296 − runs those without secrets and with a read-only token; until that exists
297 − here, they don't run. Closed and merged PRs run nothing but `closed`.
292 + lets a pull request change its own CI. For a fork or federated pull
293 + request that means the PR's author wrote the workflow, so its runs are
294 + `fork: true`: their jobs get no secrets (only the job token) and no
295 + variables, the token is read-only whatever the workflow asks for
296 + (`RunnerController.build_task/1`), and unless the author is a local
297 + user with write access to the target — and so is whoever triggered the
298 + run, when someone did — the jobs wait as `awaiting_approval` until
299 + someone with write access approves (`approve_pull_request_runs/2`).
300 + Closed and merged PRs run nothing but `closed`.
298 301
299 302 The job checks out `refs/pull/<N>/head`, which is pinned to the PR's head
300 303 here. Returns `{:ok, [run]}`.
@@ -306,14 +309,6 @@ defmodule GitGud.Workflows do
306 309 pr.state != "open" and not (action == "closed" and pr.state in ~w(closed merged)) ->
307 310 {:ok, []}
308 311
309 − PullRequest.cross_repo?(pr) or PullRequest.federated?(pr) ->
310 − Logger.debug(
311 − "not dispatching pull_request workflows for PR #{pr.id}: " <>
312 − "fork and federated pull requests would run with the target's secrets"
313 − )
314 −
315 − {:ok, []}
316 −
317 312 true ->
318 313 do_dispatch_pull_request(pr, action, opts)
319 314 end
@@ -353,26 +348,92 @@ defmodule GitGud.Workflows do
353 348 workflows,
354 349 "pull_request",
355 350 ctx,
351 + Map.merge(
352 + %{
353 + head_sha: pr.head_sha,
354 + head_ref: head_ref,
355 + triggered_by_id: triggered_by_id,
356 + pull_request_number: pr.number,
357 + pr_head_ref: Ref.name(branch_ref(pr.source_ref)),
358 + pr_base_ref: Ref.name(target_ref)
359 + },
360 + fork_attrs(repo, pr, triggered_by_id)
361 + ),
362 + fn -> pull_request_payload(repo, pr, action, target_sha, opts) end
363 + )
364 +
365 + {:ok, runs}
366 + end
367 +
368 + # `github.event` of a run for `pr` — `pull_request` and
369 + # `pull_request_target` alike. `head.repo` is where the head came from:
370 + # the fork, or the federated source's clone URL.
371 + defp pull_request_payload(repo, pr, action, target_sha, opts) do
372 + triggered_by_id = Keyword.get(opts, :triggered_by_id)
373 + pr = Repo.preload(pr, :author)
374 +
375 + repo
376 + |> Repo.preload([:owner, :organization])
377 + |> EventPayload.pull_request(
378 + pr,
379 + action,
380 + Map.merge(
356 381 %{
357 − head_sha: pr.head_sha,
358 − head_ref: head_ref,
359 − triggered_by_id: triggered_by_id,
360 − pull_request_number: pr.number,
361 − pr_head_ref: Ref.name(branch_ref(pr.source_ref)),
362 − pr_base_ref: Ref.name(target_ref)
382 + target_sha: target_sha,
383 + sender: triggered_by_id && Repo.get(User, triggered_by_id)
363 384 },
364 − fn ->
365 − repo
366 − |> Repo.preload([:owner, :organization])
367 − |> EventPayload.pull_request(Repo.preload(pr, :author), action, %{
368 − target_sha: target_sha,
369 − sender: triggered_by_id && Repo.get(User, triggered_by_id)
370 − })
371 − |> Map.merge(Keyword.get(opts, :payload, %{}))
372 − end
385 + pull_request_head(pr)
373 386 )
387 + )
388 + |> Map.merge(Keyword.get(opts, :payload, %{}))
389 + end
374 390
375 − {:ok, runs}
391 + defp pull_request_head(%PullRequest{} = pr) do
392 + cond do
393 + PullRequest.federated?(pr) ->
394 + actor = Repo.get(GitGud.Federation.Actor, pr.source_actor_id)
395 + EventPayload.federated_head(actor, pr.source_clone_url)
396 +
397 + PullRequest.cross_repo?(pr) ->
398 + source =
399 + pr.source_repository_id
400 + |> Repositories.get_repository!()
401 + |> Repo.preload([:owner, :organization])
402 +
403 + %{head_repo: Map.put(EventPayload.repository(source), "fork", true)}
404 +
405 + true ->
406 + %{}
407 + end
408 + end
409 +
410 + # A fork or federated pull request's `pull_request` run is a fork run
411 + # (see `dispatch_pull_request/3`). It is approved as it is created when
412 + # the PR's author is a local user with write access to the target, and so
413 + # is whoever triggered it (the pusher of a `synchronize`, the labeller of
414 + # a `labeled`), when someone did: a fork's push by someone else is that
415 + # someone's code.
416 + defp fork_attrs(repo, pr, triggered_by_id) do
417 + cond do
418 + not (PullRequest.cross_repo?(pr) or PullRequest.federated?(pr)) ->
419 + %{}
420 +
421 + may_write?(pr.author_id, repo) and
422 + (is_nil(triggered_by_id) or may_write?(triggered_by_id, repo)) ->
423 + %{fork: true, approved_at: DateTime.utc_now(:second)}
424 +
425 + true ->
426 + %{fork: true}
427 + end
428 + end
429 +
430 + defp may_write?(nil, _repo), do: false
431 +
432 + defp may_write?(user_id, repo) do
433 + case Repo.get(User, user_id) do
434 + %User{} = user -> GitGud.Organizations.can?(user, repo, :write) == true
435 + nil -> false
436 + end
376 437 end
377 438
378 439 defp branch_ref("refs/" <> _ = ref), do: ref
@@ -828,7 +889,9 @@ defmodule GitGud.Workflows do
828 889 # The unfinished runs of a group, oldest first, as `{id, pending?}`. A
829 890 # pending run is one whose jobs are still held for the group: its jobs
830 891 # are `waiting` without a group of their own (a job's own group is only
831 − # decided as it is queued).
892 + # decided as it is queued). A fork run waiting for approval is pending
893 + # too: it holds nobody up, and a newer run replaces it as it would a
894 + # waiting one.
832 895 defp run_group_members(repository_id, group, except_id) do
833 896 from(r in WorkflowRun,
834 897 where:
@@ -838,7 +901,7 @@ defmodule GitGud.Workflows do
838 901 select:
839 902 {r.id,
840 903 fragment(
841 − "EXISTS (SELECT 1 FROM workflow_jobs j WHERE j.workflow_run_id = ? AND j.status = 'waiting' AND j.concurrency_group IS NULL)",
904 + "EXISTS (SELECT 1 FROM workflow_jobs j WHERE j.workflow_run_id = ? AND ((j.status = 'waiting' AND j.concurrency_group IS NULL) OR j.status = 'awaiting_approval'))",
842 905 r.id
843 906 )}
844 907 )
@@ -1324,6 +1387,7 @@ defmodule GitGud.Workflows do
1324 1387 scoped_job_query(runner)
1325 1388 |> where([j], j.id == ^job_id)
1326 1389 |> where([j], j.status == "queued")
1390 + |> approved_only()
1327 1391 |> where(
1328 1392 [j],
1329 1393 fragment("(? -> 'labels') <@ to_jsonb(?::text[])", j.runs_on, ^match_labels)
@@ -1368,6 +1432,7 @@ defmodule GitGud.Workflows do
1368 1432 job =
1369 1433 scoped_job_query(runner)
1370 1434 |> where([j], j.status == "queued")
1435 + |> approved_only()
1371 1436 |> where(
1372 1437 [j],
1373 1438 fragment("(? -> 'labels') <@ to_jsonb(?::text[])", j.runs_on, ^match_labels)
@@ -1408,6 +1473,19 @@ defmodule GitGud.Workflows do
1408 1473 end
1409 1474 end
1410 1475
1476 + # A queued job of an unapproved fork run is never claimed. `enqueue_job/2`
1477 + # never queues one; this is the second lock on the same door.
1478 + defp approved_only(query) do
1479 + where(
1480 + query,
1481 + [j],
1482 + fragment(
1483 + "NOT EXISTS (SELECT 1 FROM workflow_runs fr WHERE fr.id = ? AND fr.fork AND fr.approved_at IS NULL)",
1484 + j.workflow_run_id
1485 + )
1486 + )
1487 + end
1488 +
1411 1489 # `strategy.max-parallel` (`Matrix.max_parallel/1`, pinned onto every
1412 1490 # leg): a queued leg isn't claimable while that many legs of its job are
1413 1491 # running in its run.
@@ -1907,6 +1985,9 @@ defmodule GitGud.Workflows do
1907 1985 # The `status_reason` of a job cancelled by a newer one in its group.
1908 1986 @superseded "Cancelled by a newer job in "
1909 1987
1988 + # The `status_reason` of a fork run's job held for approval.
1989 + @awaiting_approval "Waiting for approval: this pull request comes from a fork"
1990 +
1910 1991 # Queue a job whose turn has come — no `needs`, or its needs resolved,
1911 1992 # or its run's group released it — through its own `concurrency:`.
1912 1993 # `changes` are set with the status (`needs_snapshot:`). In a group
@@ -1919,11 +2000,24 @@ defmodule GitGud.Workflows do
1919 2000 # running, else is queued
1920 2001 #
1921 2002 # A group that can't be evaluated is ignored, and the job says so.
2003 + #
2004 + # A fork run nobody has approved queues nothing: the job waits as
2005 + # `awaiting_approval`, and `approve_pull_request_runs/2` brings it back
2006 + # here. Every way a job is queued passes this point, which is what keeps
2007 + # an unapproved fork's code off the runners.
1922 2008 defp enqueue_job(%WorkflowJob{} = job, changes \\ []) do
2009 + run = Repo.get!(WorkflowRun, job.workflow_run_id)
2010 +
2011 + if WorkflowRun.awaiting_approval?(run),
2012 + do:
2013 + put_job(job, changes ++ [status: "awaiting_approval", status_reason: @awaiting_approval]),
2014 + else: enqueue_approved_job(job, run, changes)
2015 + end
2016 +
2017 + defp enqueue_approved_job(%WorkflowJob{} = job, %WorkflowRun{} = run, changes) do
1923 2018 # What the job will be, for its contexts (`needs` reads the snapshot);
1924 2019 # `job` itself stays as stored, so the changes are written.
1925 2020 next = struct(job, changes)
1926 − run = Repo.get!(WorkflowRun, job.workflow_run_id)
1927 2021
1928 2022 contexts = fn ->
1929 2023 Context.for_job(next, run, Repositories.get_repository!(run.repository_id))
@@ -2316,9 +2410,10 @@ defmodule GitGud.Workflows do
2316 2410 run.status == "cancelled" ->
2317 2411 :ok
2318 2412
2319 − # `blocked` is still pending — its upstream is in flight — and
2320 − # `waiting` is held by a concurrency group.
2321 − Enum.any?(jobs, &(&1.status in ~w(queued running blocked waiting))) ->
2413 + # `blocked` is still pending — its upstream is in flight —,
2414 + # `waiting` is held by a concurrency group, and `awaiting_approval`
2415 + # by nobody having approved a fork's run yet.
2416 + Enum.any?(jobs, &(&1.status in ~w(queued running blocked waiting awaiting_approval))) ->
2322 2417 :ok
2323 2418
2324 2419 Enum.any?(jobs, &(effective_result(&1) == "failure")) ->
@@ -2501,7 +2596,7 @@ defmodule GitGud.Workflows do
2501 2596
2502 2597 defp job_commit_state(%WorkflowJob{status: status} = job) do
2503 2598 cond do
2504 − status in ~w(queued blocked waiting running) -> "pending"
2599 + status in ~w(queued blocked waiting awaiting_approval running) -> "pending"
2505 2600 status in ~w(success skipped) -> "success"
2506 2601 status == "cancelled" and superseded_by_group?(job) -> "cancelled"
2507 2602 status in ~w(failure cancelled) -> "failure"
@@ -2518,6 +2613,7 @@ defmodule GitGud.Workflows do
2518 2613 "queued" -> "Waiting to run"
2519 2614 "blocked" -> "Waiting for " <> Enum.join(job.needs, ", ")
2520 2615 "waiting" -> job.status_reason || "Waiting"
2616 + "awaiting_approval" -> "Waiting for approval"
2521 2617 "running" -> "Running"
2522 2618 "success" -> "Successful" <> took(job, " in ")
2523 2619 "failure" -> "Failing" <> took(job, " after ")
@@ -2590,6 +2686,11 @@ defmodule GitGud.Workflows do
2590 2686 pr_head_ref: old.pr_head_ref,
2591 2687 pr_base_ref: old.pr_base_ref,
2592 2688 trigger_depth: old.trigger_depth,
2689 + # A fork's run stays one: re-running it hands its jobs no more than
2690 + # the first run had, and one that was never approved still isn't.
2691 + fork: old.fork,
2692 + approved_by_id: old.approved_by_id,
2693 + approved_at: old.approved_at,
2593 2694 jobs: doc.jobs
2594 2695 })
2595 2696 end
@@ -2743,7 +2844,7 @@ defmodule GitGud.Workflows do
2743 2844 from(j in WorkflowJob,
2744 2845 where:
2745 2846 j.workflow_run_id == ^run.id and
2746 − j.status in ["queued", "running", "blocked", "waiting"],
2847 + j.status in ["queued", "running", "blocked", "waiting", "awaiting_approval"],
2747 2848 select: j
2748 2849 ),
2749 2850 set: [
@@ -2802,6 +2903,102 @@ defmodule GitGud.Workflows do
2802 2903 end
2803 2904 end
2804 2905
2906 + # ── fork pull requests: approval ─────────────────────────────────────
2907 +
2908 + @doc """
2909 + The fork runs of `pr` nobody has approved yet that are still in flight,
2910 + oldest first — on any head; `approve_pull_request_runs/2` only takes
2911 + those on the PR's current head.
2912 + """
2913 + def awaiting_approval_runs(%PullRequest{} = pr) do
2914 + from(r in WorkflowRun,
2915 + where:
2916 + r.repository_id == ^pr.repository_id and r.pull_request_number == ^pr.number and
2917 + r.fork and is_nil(r.approved_at) and r.status in ["queued", "running"],
2918 + order_by: r.id
2919 + )
2920 + |> Repo.all()
2921 + end
2922 +
2923 + @doc """
2924 + Approve the fork runs waiting on `pr`'s current head: record who and
2925 + when, and queue their `awaiting_approval` jobs (through their
2926 + concurrency groups, as any job is queued). Runs on an older head stay
2927 + as they are — that isn't the code the approver looked at — and the next
2928 + push needs approving again.
2929 +
2930 + `approver` must have write access to the target repository. Returns
2931 + `{:ok, [run]}` (the runs approved) or `{:error, :forbidden}`.
2932 + """
2933 + def approve_pull_request_runs(%PullRequest{} = pr, approver) do
2934 + repo = Repositories.get_repository!(pr.repository_id)
2935 +
2936 + if approver && GitGud.Organizations.can?(approver, repo, :write) do
2937 + now = DateTime.utc_now(:second)
2938 +
2939 + approved =
2940 + pr
2941 + |> awaiting_approval_runs()
2942 + |> Enum.filter(&(&1.head_sha == pr.head_sha))
2943 + |> Enum.flat_map(fn run ->
2944 + {:ok, result} =
2945 + Repo.transaction(fn ->
2946 + locked =
2947 + WorkflowRun |> where([r], r.id == ^run.id) |> lock("FOR UPDATE") |> Repo.one!()
2948 +
2949 + if WorkflowRun.awaiting_approval?(locked) do
2950 + locked =
2951 + locked
2952 + |> Ecto.Changeset.change(approved_by_id: approver.id, approved_at: now)
2953 + |> Repo.update!()
2954 +
2955 + from(j in WorkflowJob,
2956 + where: j.workflow_run_id == ^run.id and j.status == "awaiting_approval",
2957 + order_by: j.id
2958 + )
2959 + |> Repo.all()
2960 + |> Enum.each(&enqueue_job(&1, status_reason: nil))
2961 +
2962 + [locked]
2963 + else
2964 + []
2965 + end
2966 + end)
2967 +
2968 + result
2969 + end)
2970 +
2971 + for run <- approved, do: broadcast_ci_change(run.id)
2972 + {:ok, approved}
2973 + else
2974 + {:error, :forbidden}
2975 + end
2976 + end
2977 +
2978 + @doc """
2979 + Reject the fork runs waiting on `pr`, on any head: they are cancelled
2980 + (`cancel/2`), and nothing of theirs ever runs. `actor` must have write
2981 + access to the target repository. Returns `{:ok, [run]}` or
2982 + `{:error, :forbidden}`.
2983 + """
2984 + def reject_pull_request_runs(%PullRequest{} = pr, actor) do
2985 + repo = Repositories.get_repository!(pr.repository_id)
2986 +
2987 + if actor && GitGud.Organizations.can?(actor, repo, :write) do
2988 + reason = "Not approved: rejected by " <> (actor.handle || "a maintainer")
2989 +
2990 + cancelled =
2991 + for run <- awaiting_approval_runs(pr) do
2992 + {:ok, run} = cancel(run, reason: reason)
2993 + run
2994 + end
2995 +
2996 + {:ok, cancelled}
2997 + else
2998 + {:error, :forbidden}
2999 + end
3000 + end
3001 +
2805 3002 defp broadcast({:log, step_id, seq, body}) do
2806 3003 step = Repo.get!(WorkflowStep, step_id) |> Repo.preload(:workflow_job)
2807 3004
modified lib/git_gud/workflows/commit_status.ex
+1 −1
@@ -3,7 +3,7 @@ defmodule GitGud.Workflows.CommitStatus do
3 3 Per-commit CI / build status — one row per `(repository, sha, context)`.
4 4
5 5 `state` is the GitHub-flavored summary:
6 − * `pending` — the job is queued, blocked, waiting or running
6 + * `pending` — the job is queued, blocked, waiting, awaiting approval or running
7 7 * `success` — the job succeeded, or was skipped
8 8 * `failure` — the job failed, or was cancelled
9 9 * `cancelled` — the job was cancelled by a newer run or job in its
modified lib/git_gud/workflows/context.ex
+11 −4
@@ -23,7 +23,7 @@ defmodule GitGud.Workflows.Context do
23 23 %{
24 24 "github" => github(run, repo),
25 25 "inputs" => inputs(run),
26 − "vars" => vars(repo)
26 + "vars" => vars(run, repo)
27 27 }
28 28 end
29 29
@@ -32,7 +32,7 @@ defmodule GitGud.Workflows.Context do
32 32 %{
33 33 "github" => github(run, repo, job),
34 34 "inputs" => inputs(run),
35 − "vars" => vars(repo),
35 + "vars" => vars(run, repo),
36 36 "matrix" => job.matrix || %{},
37 37 "needs" =>
38 38 Map.new(job.needs_snapshot || %{}, fn {jid, need} ->
@@ -175,8 +175,15 @@ defmodule GitGud.Workflows.Context do
175 175
176 176 def inputs(_run), do: %{}
177 177
178 − @doc "`vars.*`: the built-ins, overridden by org and then repository variables."
179 − def vars(%Repository{} = repo) do
178 + @doc """
179 + `vars.*`: the built-ins, overridden by org and then repository variables.
180 + A fork run (`run.fork`) gets the built-ins only — the forge evaluates
181 + its `concurrency:` and matrix expressions, and their results end up in
182 + job names anyone can read.
183 + """
184 + def vars(%WorkflowRun{fork: true}, %Repository{}), do: builtin_vars()
185 +
186 + def vars(%WorkflowRun{}, %Repository{} = repo) do
180 187 {_secrets, vars} = GitGud.Secrets.resolve_for(repo)
181 188 Map.merge(builtin_vars(), vars)
182 189 end
modified lib/git_gud/workflows/event_payload.ex
+42 −5

Click to load diff…

modified lib/git_gud/workflows/workflow_job.ex
+5 −2

Click to load diff…

modified lib/git_gud/workflows/workflow_run.ex
+18 −1

Click to load diff…

modified lib/git_gud_web/controllers/api/mobile/json.ex
+6 −1

Click to load diff…

modified lib/git_gud_web/controllers/runner_controller.ex
+26 −5

Click to load diff…

modified lib/git_gud_web/live/ci_components.ex
+1 −0

Click to load diff…

modified lib/git_gud_web/live/pr_live/show.ex
+82 −2

Click to load diff…

modified lib/git_gud_web/live/workflow_live/index.ex
+1 −0

Click to load diff…

modified lib/git_gud_web/live/workflow_live/show.ex
+2 −0

Click to load diff…

added priv/repo/migrations/20261017181917_add_fork_fields_to_workflow_runs.exs
+14 −0

Click to load diff…

added test/git_gud/workflows/fork_pull_request_test.exs
+605 −0

Click to load diff…

modified test/git_gud/workflows/pull_request_dispatch_test.exs
+2 −37

Click to load diff…

Parents: a9bbffe