Workflows: run on: pull_request for same-repository pull requests

5e29d5c · Gabriel Morell · 2026-10-17 04:33

32 files +993 -75
Message
{commit_body(@commit)}

Files changed

modified PROGRESS.md
+2 −0
@@ -35,6 +35,8 @@ each gap below was a wall that pipeline hit.
35 35 ### Workflow triggers
36 36
37 37 - **`paths:` / `paths-ignore:`** are honoured for `push`, with GitHub's semantics and pattern syntax (`GitGud.Workflows.Glob`). Every push used to run every workflow: bigtrippr's Android workflow, filtered to `android/**`, ran its tests and re-published an identical APK under a new versionCode on each server-only push. The push worker now passes the ref's old SHA; dispatch lists the changed files once (`Git.changed_paths/3`, `git diff --name-only --no-renames -z`), only when some workflow filters on paths. A new branch is diffed from its merge-base with the default branch. Tags and manual runs ignore path filters; anything that can't be determined (or > 3000 files) fails open.
38 +- **`github.event_name`** is set in every job's context. It was empty, so a bigtrippr job gated on `github.event_name == 'push'` never ran.
39 +- **`on: pull_request`** for same-repository pull requests: `opened` (PR creation), `synchronize` (a push that moves an open PR's head, attributed to the pusher) and `reopened` (which first catches the head up with the branch, since closed PRs aren't refreshed on push), each through the `Workflows.Workers.PullRequestDispatch` Oban job so the LiveView and the push worker never wait on it. `Workflows.dispatch_pull_request/3` reads workflows from the PR head, pins `refs/pull/<N>/head`, and records the PR number and branches on the run (migration `add_pull_request_to_workflow_runs`) for the job's `head_ref` / `base_ref`. `types:`, `branches:` / `branches-ignore:` (the target branch) and `paths:` are honoured; `branches-ignore:` (and `tags-ignore:`) now work for `push` too. Fork and federated PRs are not dispatched — they'd run with the target's secrets — and the head is tested, not a merge result.
38 40 - Follow-up: `branches:` / `tags:` still use the old matcher, whose `*` crosses `/` (GitHub's doesn't). Left alone because changing it changes which existing branch filters fire.
39 41
40 42 ### Runner protocol
modified README.md
+2 −1
@@ -88,7 +88,8 @@ 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, and `needs:` names the group rather than a leg
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:` / `tags:`, GitHub's rule that naming one excludes the other kind of ref) and `workflow_dispatch`
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; other activity types never fire). `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 93 - **`paths:` / `paths-ignore:`** with GitHub's pattern rules (`*` stays within a directory, `**` crosses them, `?`, `[a-z]`; in `paths:`, `!pattern` un-matches and a later positive pattern re-matches). A push runs if any changed file is selected by `paths:`, or if not every changed file is matched by `paths-ignore:`; both set → `paths:` wins, with a warning. Changed files are the ref's old → new diff (renames count as both paths); a new branch is diffed from where it left the default branch. Tag pushes and manual runs ignore path filters, and when the changed files can't be worked out (no default branch to compare with, an unknown base, a git error, more than 3000 files) the filter passes — a run is never skipped because the forge couldn't look
93 94 - Re-run / cancel buttons on the workflow show page
94 95 - Per-task HS256 JWT minted on `FetchTask`, gates artifact + cache endpoints
added docs/plans/workflow-pull-request.md
+186 −0
@@ -0,0 +1,186 @@
1 +# Plan: run workflows for pull requests (`on: pull_request`)
2 +
3 +Status: **executed** (2026-10-17), on top of
4 +[workflow-path-filters.md](workflow-path-filters.md) (this reuses its
5 +changed-file listing and `paths` matching). Written to be
6 +carried out by one agent without further questions.
7 +
8 +## Why
9 +
10 +`on: pull_request` is accepted by the parser (`Yaml.matches?/3` would match
11 +it) but nothing ever dispatches that trigger: the only caller of
12 +`Workflows.dispatch/2` is the push worker
13 +(`Repositories.Workers.CommitBackfill.dispatch_workflows/4`), which always
14 +sends `"push"`. Workflows written for GitHub/Forgejo that test pull
15 +requests never run here, and the PR page's checks (it already reads commit
16 +statuses, `lib/git_gud_web/live/pr_live/show.ex`) only show whatever a
17 +push to the same SHA happened to produce.
18 +
19 +There is also a context bug to fix first: the runner context carries the
20 +trigger as `event` but not `event_name`, so `github.event_name` is empty in
21 +every job. A bigtrippr job with
22 +`if: github.event_name == 'push' && …` was skipped on every push because of
23 +it.
24 +
25 +## Scope
26 +
27 +In:
28 +- `github.event_name` in the runner context.
29 +- Same-repository pull requests (source and target branches in one repo):
30 + runs on `opened`, `synchronize` (head moved), `reopened`.
31 +- `types:` filter, `branches:` / `branches-ignore:` (against the **target**
32 + branch, as on GitHub), `paths:` / `paths-ignore:` (against the PR's diff
33 + from its merge-base).
34 +- The runner context a PR job expects: `ref`, `ref_name`, `head_ref`,
35 + `base_ref`, `sha`, `event_name`.
36 +
37 +Out (state it in the docs, don't build it):
38 +- **Cross-repository (fork) and federated pull requests.** Their head is
39 + code the target's maintainers haven't reviewed, and a job is handed the
40 + **target** repository's secrets (`runner_controller.ex`:
41 + `GitGud.Secrets.resolve_for(repo)`) and a token that can publish its
42 + packages. GitHub runs fork PRs without secrets and with a read-only
43 + token; doing that safely here is its own piece of work. Until then, do
44 + not dispatch for them at all, and log at debug level why.
45 +- `pull_request_target`, the `closed` / `edited` / `labeled` / `assigned`
46 + activity types, merge refs (`refs/pull/N/merge`; GitHub tests the merge
47 + result, this tests the head — say so in the docs).
48 +
49 +## Where things are
50 +
51 +| | |
52 +|---|---|
53 +| Runner context | `lib/git_gud_web/controllers/runner_controller.ex`, the map with `"event" => run.trigger` (~line 288) |
54 +| Ref → filter ctx | `lib/git_gud/workflows/ref.ex` (`Ref.context/1`, `Ref.name/1`, `Ref.type/1`) |
55 +| Dispatch | `lib/git_gud/workflows.ex` — `dispatch/2`, `create_run/2`; runs store `trigger`, `head_sha`, `head_ref` |
56 +| Trigger matching | `lib/git_gud/workflows/yaml.ex` |
57 +| PRs | `lib/git_gud/pull_requests.ex`: `do_create_pull_request/3` (opened), `set_state/2` (reopen: `"open"`), `refresh_cross_repo_heads/3` (called on every push via `CommitBackfill.refresh_cross_repo_prs/3`; despite the name it also covers same-repo PRs, whose `source_repository_id` is the repo itself — that is the `synchronize` moment); `PullRequest.cross_repo?/1`, `PullRequest.federated?/1` |
58 +| Commit statuses | `lib/git_gud/workflows/commit_status.ex`, `Workflows.update_run_commit_status/1` (keyed on the run's head SHA, which the PR page reads) |
59 +| Tests to copy from | `test/git_gud/workflows/tag_dispatch_test.exs` (on-disk repo + dispatch); find the existing PR tests under `test/git_gud/pull_requests*` for creating PRs in tests |
60 +
61 +## Steps
62 +
63 +### 1. `github.event_name`
64 +
65 +In the runner context map add `"event_name" => run.trigger || "push"`
66 +next to `"event"`. Test: the existing runner controller test that asserts
67 +on the context (find it: grep `ref_name` under `test/`) gets an assertion
68 +for `event_name`. This step is independent: commit it on its own first.
69 +
70 +### 2. Trigger matching for pull requests
71 +
72 +In `Yaml`:
73 +- `pull_request` config's `types:` — default `["opened", "synchronize",
74 + "reopened"]` when absent; the event's action must be in the list.
75 +- `branches:` / `branches-ignore:` match the **target** branch (ctx
76 + `:branch` is the target's short name). `branches-ignore` may not be
77 + supported for push either — add it for both if missing, with GitHub's
78 + rule that `branches` and `branches-ignore` can't both be set (if both:
79 + use `branches`, log a warning; same choice as paths).
80 +- `paths` / `paths-ignore`: unchanged code from the path-filters plan;
81 + ctx `:changed_paths` is the PR's diff (step 3).
82 +- `tags:` never applies to pull requests.
83 +
84 +`matches?/3` gains the action through ctx (`:action`), not a new arity.
85 +Pure tests in `trigger_filters_test.exs`: types default and explicit;
86 +target-branch filter; `branches-ignore`; paths on a PR.
87 +
88 +### 3. `Workflows.dispatch_pull_request(pr, action, opts)`
89 +
90 +New public function in `GitGud.Workflows`:
91 +
92 +- Return `{:ok, []}` without doing anything when `pr.state != "open"`,
93 + `PullRequest.cross_repo?(pr)` or `PullRequest.federated?(pr)`.
94 +- Workflows are read from the PR **head** commit (`pr.head_sha`) — that is
95 + GitHub's behaviour for `pull_request` and what lets a PR change its own
96 + CI. (Fork PRs are excluded above, so this is code from the repo's own
97 + writers.)
98 +- Pin `refs/pull/<N>/head` to `pr.head_sha` in the repo
99 + (`Git.update_ref/4`, as `pin_pull_head`/`refresh_*` already do for
100 + cross-repo PRs), so the ref the job is told about exists and
101 + `actions/checkout` can fetch it.
102 +- Changed paths: `Git.changed_paths(repo_path, merge_base(target head,
103 + pr.head_sha), pr.head_sha)` — only if some workflow is path-filtered;
104 + fail open as in the path plan.
105 +- ctx: `%{branch: target short name, action: action, changed_paths: …}`.
106 +- Create runs with `trigger: "pull_request"`, `head_sha: pr.head_sha`,
107 + `head_ref: "refs/pull/<N>/head"`, and store the PR's number, source and
108 + target refs on the run. Add a migration
109 + (`mix ecto.gen.migration add_pull_request_to_workflow_runs`) with
110 + nullable `pull_request_number :integer`, `pr_head_ref :string`,
111 + `pr_base_ref :string` on `workflow_runs`, and cast them in
112 + `WorkflowRun`'s changeset. Refactor `dispatch/2`'s inner loop into a
113 + shared private function rather than duplicating it.
114 +- Return `{:ok, runs}`.
115 +
116 +### 4. The runner context for a PR job
117 +
118 +In `runner_controller.ex`, when `run.trigger == "pull_request"`:
119 +- `"ref" => "refs/pull/<N>/head"` (already the run's `head_ref`),
120 + `"ref_name" => "<N>/head"`, `"ref_type" => "branch"`? — GitHub sets
121 + `ref_type` empty for PRs; use `""`.
122 +- `"head_ref" => run.pr_head_ref`, `"base_ref" => run.pr_base_ref` (short
123 + branch names, as GitHub gives them).
124 +- `"event_name" => "pull_request"` (from step 1).
125 +- `"event"` stays the trigger string — check what forgejo-runner/act does
126 + with `event` (it may expect the event *payload* object there; if the
127 + existing push jobs work with a string, keep it, and do not change push
128 + behaviour in this plan).
129 +
130 +Make sure `Ref.name/1` / `Ref.type/1` are not fed `refs/pull/...` paths
131 +that make them crash or misreport; add clauses or bypass them for PR runs.
132 +
133 +### 5. Call sites
134 +
135 +- **opened**: at the end of `do_create_pull_request/3`, after the
136 + transaction succeeds, call `Workflows.dispatch_pull_request(pr, "opened",
137 + triggered_by_id: author.id)`. Do it outside the transaction; a dispatch
138 + failure must never fail PR creation (rescue + log).
139 +- **synchronize**: in `refresh_cross_repo_heads/3`, for each PR whose
140 + `head_sha` actually changed (it has `old_sha`), call it with
141 + `"synchronize"` and the pusher if available (thread `pusher_id` from
142 + `CommitBackfill.refresh_cross_repo_prs/3` if it's cheap; else nil).
143 + This function already handles same-repo PRs; cross-repo ones are dropped
144 + inside `dispatch_pull_request/3`.
145 +- **reopened**: in `set_state/2` when moving `"closed"` → `"open"`
146 + successfully. Check whether the head moved while closed (re-resolve the
147 + source ref) — if `refresh_*` already keeps `head_sha` current for closed
148 + PRs, no extra work; otherwise refresh `head_sha` before dispatching.
149 +- Dispatch is DB + git work; if PR creation runs inside a LiveView
150 + process, enqueue it (Oban, like `CommitBackfill`) instead of running it
151 + inline. Look at how `CommitBackfill` is enqueued and copy that shape
152 + (`Workers.PullRequestDispatch`, args: pr id, action, actor id).
153 +
154 +### 6. Tests
155 +
156 +On disk, modelled on `tag_dispatch_test.exs`:
157 +- same-repo PR opened → a `pull_request` run on the PR head SHA, with
158 + `head_ref` `refs/pull/N/head`, and that ref exists in the repo.
159 +- push to the source branch → a `synchronize` run (and the usual push run).
160 +- `types: [opened]` → no run on synchronize.
161 +- `branches: [master]` → runs for a PR targeting master, not one targeting
162 + `release`.
163 +- `paths: ["lib/**"]` → PR that only changes `docs/` → no run.
164 +- workflow present only on the PR branch (added by the PR) → it runs
165 + (workflows come from the head).
166 +- cross-repo PR (fork fixture, if the test helpers have one; else build
167 + the struct with a different `source_repository_id`) → no run, and the
168 + target's secrets are never resolved for it.
169 +- closed then reopened → a `reopened` run.
170 +- runner context for a PR job (controller test): `event_name`,
171 + `head_ref`, `base_ref`, `ref`, `ref_name`.
172 +
173 +### 7. Docs
174 +
175 +Where workflow support is documented (see the path plan's step 7): `on:
176 +pull_request` with the supported `types`, that it tests the PR head (not a
177 +merge result), that `branches` means the target branch, and that fork and
178 +federated PRs don't run yet and why (secrets).
179 +
180 +## Done when
181 +
182 +- `mix precommit` passes.
183 +- Commits, in the repository's message style: (1) `event_name`, (2) the
184 + pull request trigger (migration included). Separate from the path-filter
185 + commit.
186 +- **Not pushed.** Pushing gitgud deploys the forge.
modified lib/git_gud/pull_requests.ex
+35 −4
@@ -24,6 +24,7 @@ defmodule GitGud.PullRequests do
24 24 alias GitGud.Repositories
25 25 alias GitGud.Repositories.Numbering
26 26 alias GitGud.Repositories.Repository
27 + alias GitGud.Workflows.Workers.PullRequestDispatch
27 28
28 29 @target_type "pull_request"
29 30
@@ -245,6 +246,7 @@ defmodule GitGud.PullRequests do
245 246
246 247 with {:ok, pr} <- result do
247 248 _ = GitGud.Federation.Publishing.publish_pr_opened(repo, pr, author)
249 + :ok = PullRequestDispatch.enqueue(pr, "opened", uid)
248 250 {:ok, pr}
249 251 end
250 252 end
@@ -464,7 +466,7 @@ defmodule GitGud.PullRequests do
464 466 the target repo for every open cross-repo PR that has `source_repo` +
465 467 `branch` as its source, and re-snapshots `head_sha` on the PR row.
466 468 """
467 − def refresh_cross_repo_heads(%Repository{} = source_repo, branch, new_sha) do
469 + def refresh_cross_repo_heads(%Repository{} = source_repo, branch, new_sha, pusher_id \\ nil) do
468 470 prs =
469 471 Repo.all(
470 472 from p in PullRequest,
@@ -507,6 +509,10 @@ defmodule GitGud.PullRequests do
507 509 to: Git.to_hex(new_sha),
508 510 commits: count_new_commits(source_repo, old_sha, new_sha)
509 511 })
512 +
513 + # `pull_request` workflows' `synchronize`. Fork PRs pass through
514 + # here too and are turned away by the dispatcher.
515 + :ok = PullRequestDispatch.enqueue(updated, "synchronize", pusher_id)
510 516 end
511 517 end
512 518
@@ -573,9 +579,34 @@ defmodule GitGud.PullRequests do
573 579 def set_state(%PullRequest{state: "merged"}, "open"), do: {:error, :merged}
574 580
575 581 def set_state(%PullRequest{} = pr, state) when state in ["open", "closed"] do
576 − pr
577 − |> PullRequest.state_changeset(state)
578 − |> Repo.update()
582 + result =
583 + pr
584 + |> PullRequest.state_changeset(state)
585 + |> Repo.update()
586 +
587 + case {pr.state, result} do
588 + {"closed", {:ok, %PullRequest{state: "open"} = reopened}} ->
589 + # Pushes to a closed PR's branch don't move its head (only open PRs
590 + # are refreshed on push), so catch up before its CI runs.
591 + reopened = refresh_head_on_reopen(reopened)
592 + :ok = PullRequestDispatch.enqueue(reopened, "reopened", nil)
593 + {:ok, reopened}
594 +
595 + _ ->
596 + result
597 + end
598 + end
599 +
600 + # Same-repository PRs only: a fork's branch lives in another repository
601 + # and would need fetching, and its CI doesn't run here anyway.
602 + defp refresh_head_on_reopen(%PullRequest{} = pr) do
603 + with false <- PullRequest.cross_repo?(pr) or PullRequest.federated?(pr),
604 + %Repository{} = repo <- Repo.get(Repository, pr.repository_id),
605 + {:ok, head} when head != pr.head_sha <- Repositories.resolve(repo, pr.source_ref) do
606 + pr |> Ecto.Changeset.change(head_sha: head) |> Repo.update!()
607 + else
608 + _ -> pr
609 + end
579 610 end
580 611
581 612 # ── mergeability ─────────────────────────────────────────────────────
modified lib/git_gud/repositories/workers/commit_backfill.ex
+3 −3
@@ -42,7 +42,7 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
42 42 Webhooks.notify(repo, "push", push_payload(repo, ref, old_sha, new_sha, :update))
43 43 dispatch_workflows(repo, ref, old_sha, new_sha, pusher_id)
44 44 publish_push(repo, ref, old_sha, new_sha)
45 − refresh_cross_repo_prs(repo, ref, new_sha)
45 + refresh_cross_repo_prs(repo, ref, new_sha, pusher_id)
46 46 enqueue_language_stats(repo, ref, new_sha)
47 47 :ok
48 48 end
@@ -62,7 +62,7 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
62 62
63 63 defp enqueue_language_stats(_repo, _ref, _sha), do: :ok
64 64
65 − defp refresh_cross_repo_prs(repo, ref, new_sha) do
65 + defp refresh_cross_repo_prs(repo, ref, new_sha, pusher_id) do
66 66 branch =
67 67 case ref do
68 68 "refs/heads/" <> b -> b
@@ -70,7 +70,7 @@ defmodule GitGud.Repositories.Workers.CommitBackfill do
70 70 end
71 71
72 72 if branch do
73 − GitGud.PullRequests.refresh_cross_repo_heads(repo, branch, new_sha)
73 + GitGud.PullRequests.refresh_cross_repo_heads(repo, branch, new_sha, pusher_id)
74 74 end
75 75 end
76 76
modified lib/git_gud/workflows.ex
+123 −27

Click to load diff…

added lib/git_gud/workflows/workers/pull_request_dispatch.ex
+73 −0

Click to load diff…

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

Click to load diff…

modified lib/git_gud/workflows/yaml.ex
+75 −39

Click to load diff…

modified lib/git_gud_web/controllers/runner_controller.ex
+23 −0

Click to load diff…

added priv/repo/migrations/20261017042727_add_pull_request_to_workflow_runs.exs
+14 −0

Click to load diff…

added test/git_gud/workflows/pull_request_dispatch_test.exs
+317 −0

Click to load diff…

modified test/git_gud/workflows/trigger_filters_test.exs
+111 −0

Click to load diff…

modified test/git_gud_web/controllers/runner_controller_test.exs
+18 −0

Click to load diff…

Parents: 0239060