neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: on: pull_request_target runs the target branch's workflow
a5b815d · Gabriel Morell · 2026-10-17 18:51
Message
{commit_body(@commit)}
Files changed
modified
README.md
+4
−3
@@ -88,9 +88,10 @@ reinvent them.
| 88 | 88 | - Repo-scoped + org-scoped runner registration with one-shot tokens minted from the settings UI |
| 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 | − - **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`. | |
| 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 | |
| 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`, `pull_request_target` and `workflow_dispatch` | |
| 92 | + - **`on: pull_request`**: `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` | |
| 94 | + - **`on: pull_request_target`**, with the same `types:` / `branches:` / `paths:` filters as `pull_request`, runs the workflow from the **target branch's head**, on that commit (`github.sha` is the target's head, `github.ref` `refs/heads/<target>`, `ref_name`/`ref_type` the branch's; `head_ref`/`base_ref` and `github.event.pull_request` as for `pull_request`) — for every pull request, same-repository, fork or federated, with the target's secrets and variables and a normal token, and without approval, as on GitHub, because the code that runs is the target's own. A `pull_request_target` workflow the pull request adds doesn't run: it isn't on the target branch. `[skip ci]` doesn't apply to it. **Checking out the pull request's head (`github.event.pull_request.head.sha`) in such a job and running anything from it is the classic way to leak a repository's secrets**: that code then runs with them, and its cache saves land in the target branch's scope | |
| 94 | 95 | - **`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) |
| 95 | 96 | - **`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 |
| 96 | 97 | - **`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` |
modified
lib/git_gud/workflows.ex
+63
−0
@@ -436,6 +436,69 @@ defmodule GitGud.Workflows do
| 436 | 436 | end |
| 437 | 437 | end |
| 438 | 438 | |
| 439 | + @doc """ | |
| 440 | + Start the `on: pull_request_target` workflows for a pull request — any | |
| 441 | + pull request, same-repository, fork or federated alike, and without | |
| 442 | + approval. | |
| 443 | + | |
| 444 | + The workflows are the **target branch's**, read at its head, and the run | |
| 445 | + is on that commit (`github.sha`, `github.ref` = `refs/heads/<target>`), | |
| 446 | + so the code that runs is the target's own: it gets the target's secrets, | |
| 447 | + variables and a normal token. The pull request is in | |
| 448 | + `github.event.pull_request`, filters as for `pull_request` (`types:`, | |
| 449 | + `branches:` on the target, `paths:` on the PR's diff). A workflow added | |
| 450 | + by the pull request itself doesn't run — it isn't on the target branch. | |
| 451 | + Checking out the PR's head in such a job hands that code those secrets. | |
| 452 | + `[skip ci]` doesn't apply, as on GitHub. | |
| 453 | + | |
| 454 | + Same `action`s and `opts` as `dispatch_pull_request/3`. Returns | |
| 455 | + `{:ok, [run]}`. | |
| 456 | + """ | |
| 457 | + def dispatch_pull_request_target(%PullRequest{} = pr, action, opts \\ []) | |
| 458 | + when is_binary(action) do | |
| 459 | + if pr.state != "open" and not (action == "closed" and pr.state in ~w(closed merged)), | |
| 460 | + do: {:ok, []}, | |
| 461 | + else: do_dispatch_pull_request_target(pr, action, opts) | |
| 462 | + end | |
| 463 | + | |
| 464 | + defp do_dispatch_pull_request_target(%PullRequest{} = pr, action, opts) do | |
| 465 | + repo = Repositories.get_repository!(pr.repository_id) | |
| 466 | + target_ref = branch_ref(pr.target_ref) | |
| 467 | + | |
| 468 | + case ref_head(repo, target_ref) do | |
| 469 | + nil -> | |
| 470 | + {:ok, []} | |
| 471 | + | |
| 472 | + target_sha -> | |
| 473 | + workflows = discover_workflows(repo, target_sha) | |
| 474 | + | |
| 475 | + ctx = | |
| 476 | + %{branch: Ref.name(target_ref), action: action} | |
| 477 | + |> put_changed_paths(workflows, "pull_request_target", fn -> | |
| 478 | + changed_paths(repo, merge_base_of(repo, pr.head_sha, target_sha), pr.head_sha) | |
| 479 | + end) | |
| 480 | + | |
| 481 | + runs = | |
| 482 | + create_matching_runs( | |
| 483 | + repo, | |
| 484 | + workflows, | |
| 485 | + "pull_request_target", | |
| 486 | + ctx, | |
| 487 | + %{ | |
| 488 | + head_sha: target_sha, | |
| 489 | + head_ref: target_ref, | |
| 490 | + triggered_by_id: Keyword.get(opts, :triggered_by_id), | |
| 491 | + pull_request_number: pr.number, | |
| 492 | + pr_head_ref: Ref.name(branch_ref(pr.source_ref)), | |
| 493 | + pr_base_ref: Ref.name(target_ref) | |
| 494 | + }, | |
| 495 | + fn -> pull_request_payload(repo, pr, action, target_sha, opts) end | |
| 496 | + ) | |
| 497 | + | |
| 498 | + {:ok, runs} | |
| 499 | + end | |
| 500 | + end | |
| 501 | + | |
| 439 | 502 | defp branch_ref("refs/" <> _ = ref), do: ref |
| 440 | 503 | defp branch_ref(name), do: "refs/heads/" <> name |
| 441 | 504 |
modified
lib/git_gud/workflows/context.ex
+7
−0
@@ -147,6 +147,13 @@ defmodule GitGud.Workflows.Context do
| 147 | 147 | } |
| 148 | 148 | end |
| 149 | 149 | |
| 150 | + # A `pull_request_target` job runs on the target branch, so `ref`, | |
| 151 | + # `ref_name` and `ref_type` are that branch's; `head_ref` / `base_ref` | |
| 152 | + # are the PR's, as for `pull_request`. | |
| 153 | + defp pull_request_context(%{trigger: "pull_request_target"} = run) do | |
| 154 | + %{"head_ref" => run.pr_head_ref || "", "base_ref" => run.pr_base_ref || ""} | |
| 155 | + end | |
| 156 | + | |
| 150 | 157 | defp pull_request_context(_run), do: %{} |
| 151 | 158 | |
| 152 | 159 | defp pull_ref_name(%{head_ref: "refs/pull/" <> name}), do: name |
modified
lib/git_gud/workflows/workers/pull_request_dispatch.ex
+5
−8
@@ -1,7 +1,7 @@
| 1 | 1 | defmodule GitGud.Workflows.Workers.PullRequestDispatch do |
| 2 | 2 | @moduledoc """ |
| 3 | − Starts a pull request's `on: pull_request` workflows, off the request | |
| 4 | − path. | |
| 3 | + Starts a pull request's `on: pull_request` and `on: pull_request_target` | |
| 4 | + workflows, off the request path. | |
| 5 | 5 | |
| 6 | 6 | Opening and reopening a PR happen in a LiveView, and synchronizing in the |
| 7 | 7 | post-receive worker; dispatch reads the tree and writes runs, which none |
@@ -75,12 +75,9 @@ defmodule GitGud.Workflows.Workers.PullRequestDispatch do
| 75 | 75 | with %PullRequest{} = pr <- Repo.get(PullRequest, id), |
| 76 | 76 | true <- pr.head_sha != nil, |
| 77 | 77 | true <- action not in @head_actions or Git.to_hex(pr.head_sha) == head do |
| 78 | − {:ok, _runs} = | |
| 79 | − Workflows.dispatch_pull_request(pr, action, | |
| 80 | − triggered_by_id: args["actor_id"], | |
| 81 | − payload: args["extra"] || %{} | |
| 82 | − ) | |
| 83 | − | |
| 78 | + opts = [triggered_by_id: args["actor_id"], payload: args["extra"] || %{}] | |
| 79 | + {:ok, _runs} = Workflows.dispatch_pull_request(pr, action, opts) | |
| 80 | + {:ok, _runs} = Workflows.dispatch_pull_request_target(pr, action, opts) | |
| 84 | 81 | :ok |
| 85 | 82 | else |
| 86 | 83 | # Deleted, or superseded by a later push. |
modified
lib/git_gud/workflows/yaml.ex
+6
−3
@@ -154,7 +154,9 @@ defmodule GitGud.Workflows.Yaml do
| 154 | 154 | # GitHub's default `types` for `pull_request`. `closed`, `edited`, |
| 155 | 155 | # `labeled` and `unlabeled` are dispatched too, but only run a workflow |
| 156 | 156 | # whose `types:` names them. |
| 157 | − defp type_match?("pull_request", config, %{action: action}) when is_binary(action) do | |
| 157 | + # `pull_request_target` takes the same `types:`. | |
| 158 | + defp type_match?(event, config, %{action: action}) | |
| 159 | + when event in ~w(pull_request pull_request_target) and is_binary(action) do | |
| 158 | 160 | types = config |> Map.get("types", @pull_request_types) |> List.wrap() |
| 159 | 161 | action in Enum.map(types, &to_string/1) |
| 160 | 162 | end |
@@ -172,8 +174,9 @@ defmodule GitGud.Workflows.Yaml do
| 172 | 174 | |
| 173 | 175 | # A pull request is filtered on its target branch; `tags:` has nothing to |
| 174 | 176 | # say about it. |
| 175 | − defp ref_match?("pull_request", config, %{branch: branch}, doc) when is_binary(branch), | |
| 176 | − do: kind_match?(config, {"branches", "branches-ignore"}, [], branch, doc) | |
| 177 | + defp ref_match?(event, config, %{branch: branch}, doc) | |
| 178 | + when event in ~w(pull_request pull_request_target) and is_binary(branch), | |
| 179 | + do: kind_match?(config, {"branches", "branches-ignore"}, [], branch, doc) | |
| 177 | 180 | |
| 178 | 181 | # `workflow_run`'s `branches:` filter the triggering run's head branch. |
| 179 | 182 | defp ref_match?("workflow_run", config, %{branch: branch}, doc) when is_binary(branch), |
modified
test/git_gud/workflows/fork_pull_request_test.exs
+125
−0
@@ -429,6 +429,120 @@ defmodule GitGud.Workflows.ForkPullRequestTest do
| 429 | 429 | end |
| 430 | 430 | end |
| 431 | 431 | |
| 432 | + describe "pull_request_target" do | |
| 433 | + @target_wf ".forgejo/workflows/target.yml" | |
| 434 | + | |
| 435 | + @target_workflow """ | |
| 436 | + name: label | |
| 437 | + on: | |
| 438 | + pull_request_target: | |
| 439 | + branches: [main] | |
| 440 | + jobs: | |
| 441 | + label: | |
| 442 | + runs-on: self-hosted | |
| 443 | + steps: | |
| 444 | + - run: echo labelling | |
| 445 | + """ | |
| 446 | + | |
| 447 | + defp target_runs(%Repository{id: rid}) do | |
| 448 | + from(r in WorkflowRun, | |
| 449 | + where: r.repository_id == ^rid and r.trigger == "pull_request_target", | |
| 450 | + order_by: r.id | |
| 451 | + ) | |
| 452 | + |> Repo.all() | |
| 453 | + end | |
| 454 | + | |
| 455 | + # `main` advanced with the pull_request_target workflow on it. | |
| 456 | + defp with_target_workflow!(upstream, main) do | |
| 457 | + head = commit!(upstream, %{@wf => @workflow, @target_wf => @target_workflow}, [main]) | |
| 458 | + git!(upstream.disk_path, ["update-ref", "refs/heads/main", head]) | |
| 459 | + head | |
| 460 | + end | |
| 461 | + | |
| 462 | + test "the target branch's workflow runs for a fork's PR, at the target head, with secrets", | |
| 463 | + %{conn: conn, upstream: upstream, main: main} do | |
| 464 | + target_head = with_target_workflow!(upstream, main) | |
| 465 | + :ok = put_secret(upstream, "DEPLOY_KEY", "hunter2") | |
| 466 | + {:ok, _} = GitGud.Secrets.put_repo_variable(upstream, "INTERNAL_HOST", "db.internal") | |
| 467 | + | |
| 468 | + {forker, fork, feature} = fork_with_feature!(upstream, main) | |
| 469 | + pr = open_fork_pr!(upstream, fork, forker) | |
| 470 | + | |
| 471 | + assert [run] = target_runs(upstream) | |
| 472 | + refute run.fork | |
| 473 | + assert run.head_sha == sha!(target_head) | |
| 474 | + assert run.head_ref == "refs/heads/main" | |
| 475 | + assert run.pull_request_number == pr.number | |
| 476 | + assert run.pr_head_ref == "feature" | |
| 477 | + assert run.pr_base_ref == "main" | |
| 478 | + assert run.event_payload["pull_request"]["head"]["sha"] == feature | |
| 479 | + assert run.event_payload["pull_request"]["head"]["repo"]["id"] == fork.id | |
| 480 | + | |
| 481 | + # No approval: it's the target's own code. | |
| 482 | + assert [%WorkflowJob{status: "queued"}] = jobs(run) | |
| 483 | + | |
| 484 | + {_runner, runner_token} = runner!() | |
| 485 | + task = fetch_task!(conn, runner_token) | |
| 486 | + assert task.secrets["DEPLOY_KEY"] == "hunter2" | |
| 487 | + assert task.vars["INTERNAL_HOST"] == "db.internal" | |
| 488 | + | |
| 489 | + context = plain(task.context) | |
| 490 | + assert context["event_name"] == "pull_request_target" | |
| 491 | + assert context["ref"] == "refs/heads/main" | |
| 492 | + assert context["ref_name"] == "main" | |
| 493 | + assert context["ref_type"] == "branch" | |
| 494 | + assert context["sha"] == target_head | |
| 495 | + assert context["head_ref"] == "feature" | |
| 496 | + assert context["base_ref"] == "main" | |
| 497 | + assert context["event"]["number"] == pr.number | |
| 498 | + | |
| 499 | + {:ok, claims} = RunnerJwt.verify(task.secrets["GITHUB_TOKEN"]) | |
| 500 | + assert claims["perm"]["packages"] == "write" | |
| 501 | + end | |
| 502 | + | |
| 503 | + test "a pull_request_target workflow the PR adds doesn't run", %{ | |
| 504 | + upstream: upstream, | |
| 505 | + main: main | |
| 506 | + } do | |
| 507 | + forker = user_fixture() | |
| 508 | + {:ok, fork} = Repositories.fork(upstream, forker, forker) | |
| 509 | + | |
| 510 | + feature = | |
| 511 | + commit!(fork, %{@wf => @workflow, @target_wf => @target_workflow, "lib/a" => "a\n"}, [ | |
| 512 | + main | |
| 513 | + ]) | |
| 514 | + | |
| 515 | + git!(fork.disk_path, ["update-ref", "refs/heads/feature", feature]) | |
| 516 | + open_fork_pr!(upstream, fork, forker) | |
| 517 | + | |
| 518 | + assert target_runs(upstream) == [] | |
| 519 | + # Its pull_request workflow does, as a fork run. | |
| 520 | + assert [%WorkflowRun{fork: true}] = pr_runs(upstream) | |
| 521 | + end | |
| 522 | + | |
| 523 | + test "runs for same-repository pull requests too, and filters like pull_request", %{ | |
| 524 | + upstream: upstream, | |
| 525 | + main: main, | |
| 526 | + maintainer: maintainer | |
| 527 | + } do | |
| 528 | + target_head = with_target_workflow!(upstream, main) | |
| 529 | + feature = commit!(upstream, %{@wf => @workflow, "lib/a" => "a\n"}, [target_head]) | |
| 530 | + git!(upstream.disk_path, ["update-ref", "refs/heads/feature", feature]) | |
| 531 | + | |
| 532 | + {:ok, pr} = | |
| 533 | + PullRequests.create_pull_request(upstream, maintainer, pr_attrs("feature", "main")) | |
| 534 | + | |
| 535 | + drain!() | |
| 536 | + | |
| 537 | + assert [%WorkflowRun{head_ref: "refs/heads/main"}] = target_runs(upstream) | |
| 538 | + | |
| 539 | + # `edited` isn't in the default types. | |
| 540 | + {:ok, _} = PullRequests.update_pull_request(pr, %{"title" => "renamed"}) | |
| 541 | + drain!() | |
| 542 | + assert [_only] = target_runs(upstream) | |
| 543 | + end | |
| 544 | + end | |
| 545 | + | |
| 432 | 546 | describe "the pull request page" do |
| 433 | 547 | defp pr_path(repo, owner, pr), |
| 434 | 548 | do: "/r/#{Repositories.Storage.owner_handle(owner)}/#{repo.name}/pulls/#{pr.number}" |
@@ -504,6 +618,17 @@ defmodule GitGud.Workflows.ForkPullRequestTest do
| 504 | 618 | task |
| 505 | 619 | end |
| 506 | 620 | |
| 621 | + defp plain(%Google.Protobuf.Struct{fields: fields}), | |
| 622 | + do: Map.new(fields, fn {k, v} -> {k, plain_value(v)} end) | |
| 623 | + | |
| 624 | + defp plain_value(%Google.Protobuf.Value{kind: {:struct_value, struct}}), do: plain(struct) | |
| 625 | + | |
| 626 | + defp plain_value(%Google.Protobuf.Value{kind: {:list_value, %{values: values}}}), | |
| 627 | + do: Enum.map(values, &plain_value/1) | |
| 628 | + | |
| 629 | + defp plain_value(%Google.Protobuf.Value{kind: {:null_value, _}}), do: nil | |
| 630 | + defp plain_value(%Google.Protobuf.Value{kind: {_kind, v}}), do: v | |
| 631 | + | |
| 507 | 632 | defp put_secret(repo, name, value) do |
| 508 | 633 | case GitGud.Secrets.put_repo_secret(repo, name, value) do |
| 509 | 634 | {:ok, _} -> :ok |
Parents: 1b35c31