Actions: on: pull_request_target runs the target branch's workflow

a5b815d · Gabriel Morell · 2026-10-17 18:51

13 files +210 -14
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