neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Actions: fork and federated pull requests run, after approval, without secrets
1b35c31 · Gabriel Morell · 2026-10-17 18:44
Message
{commit_body(@commit)}
Files changed
modified
README.md
+3
−2
| 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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 | 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