Actions: on: workflow_run fires, and the webhook reports failed runs

a9bbffe · Gabriel Morell · 2026-10-17 18:13

18 files +443 -37
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -93,6 +93,7 @@ reinvent them.
93 93 - **`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 94 - **`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 95 - **`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`
96 + - **`on: workflow_run`** (`workflows:` by `name:`, `types:` `completed` / `requested` (the default) / `in_progress`, `branches:` on the triggering run's head branch): runs the **default branch's** workflows at its head, whether the triggering run succeeded or failed (`github.event.workflow_run.conclusion`); chains stop three levels deep. The `workflow_run` **webhook** fires for every finished run, failed ones included
96 97 - **`[skip ci]`**, `[ci skip]`, `[no ci]`, `[skip actions]` or `[actions skip]` in the head commit's message (any case), or a `skip-checks: true` trailer, starts no `push` or `pull_request` runs — the head commit only, as on GitHub; other events aren't skipped
97 98 - **Filter patterns** in `branches:`, `tags:`, `paths:` and their `-ignore` lists follow GitHub's cheat sheet: `*` is any run of characters but `/`, `**` any run at all (`**/` also no directory), `?` zero or one and `+` one or more of the **preceding** character, `[a-z0-9]` one listed character (alphanumerics and `a-z`/`A-Z`/`0-9` ranges only), `\x` a literal `x`. In `branches:`, `tags:` and `paths:` the last matching pattern decides, so `!pattern` excludes and a later positive pattern includes again; a list with only `!` patterns matches nothing. `*` no longer crosses `/` in `branches:` / `tags:` (`branches: ['*']` doesn't run for `feature/x`), and `!` works there now. Branch protection and required-check patterns are separate: their `*` still crosses `/`
98 99 - **`paths:` / `paths-ignore:`**: 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
modified lib/git_gud/workflows.ex
+94 −32
@@ -37,6 +37,8 @@ defmodule GitGud.Workflows do
37 37
38 38 @workflow_dirs ~w(.github/workflows .forgejo/workflows)
39 39
40 + @max_trigger_depth 3
41 +
40 42 # ── dispatch ─────────────────────────────────────────────────────────
41 43
42 44 @doc """
@@ -225,6 +227,57 @@ defmodule GitGud.Workflows do
225 227 {:ok, runs}
226 228 end
227 229
230 + @doc """
231 + Start the `on: workflow_run` workflows for `parent` (a `%WorkflowRun{}`)
232 + reaching `action` — `"requested"`, `"in_progress"` or `"completed"`.
233 +
234 + As on GitHub they are the **default branch's** workflows at its head,
235 + and one matches when its `workflows:` names the parent's workflow (its
236 + `name:`, or its path when it has none), its `types:` (default
237 + `completed` and `requested`) has `action`, and its `branches:` /
238 + `branches-ignore:` pass the parent's head branch. The new run is one hop
239 + deeper than its parent (`trigger_depth`); a parent at
240 + `max_trigger_depth/0` starts nothing. Returns `{:ok, [run]}`.
241 + """
242 + def dispatch_workflow_run(%WorkflowRun{} = parent, action) when is_binary(action) do
243 + repo = Repositories.get_repository!(parent.repository_id)
244 +
245 + with true <- (parent.trigger_depth || 0) < @max_trigger_depth,
246 + {:ok, ref, sha} <- default_branch_head(repo) do
247 + ctx = %{
248 + action: action,
249 + workflow: parent.workflow_name || parent.workflow_path,
250 + branch: EventPayload.run_head_branch(parent)
251 + }
252 +
253 + runs =
254 + create_matching_runs(
255 + repo,
256 + discover_workflows(repo, sha),
257 + "workflow_run",
258 + ctx,
259 + %{
260 + head_sha: sha,
261 + head_ref: ref,
262 + triggered_by_id: parent.triggered_by_id,
263 + trigger_depth: (parent.trigger_depth || 0) + 1
264 + },
265 + fn ->
266 + EventPayload.workflow_run(
267 + Repo.preload(repo, [:owner, :organization]),
268 + parent,
269 + action,
270 + parent.triggered_by_id && Repo.get(User, parent.triggered_by_id)
271 + )
272 + end
273 + )
274 +
275 + {:ok, runs}
276 + else
277 + _ -> {:ok, []}
278 + end
279 + end
280 +
228 281 @doc """
229 282 Start the `on: pull_request` workflows for a pull request.
230 283
@@ -698,6 +751,7 @@ defmodule GitGud.Workflows do
698 751 |> case do
699 752 {:ok, {run, hold, bad_matrices}} ->
700 753 broadcast_ci_change(run.id)
754 + notify_workflow_run(run, "requested")
701 755
702 756 for {row, reason} <- bad_matrices, do: fail_matrix(row, reason)
703 757
@@ -1291,6 +1345,7 @@ defmodule GitGud.Workflows do
1291 1345
1292 1346 if run.status == "queued" do
1293 1347 run |> WorkflowRun.status_changeset("running") |> Repo.update!()
1348 + notify_workflow_run(run, "in_progress")
1294 1349 end
1295 1350
1296 1351 update_job_commit_status(claimed, run)
@@ -1338,6 +1393,7 @@ defmodule GitGud.Workflows do
1338 1393
1339 1394 if run.status == "queued" do
1340 1395 run |> WorkflowRun.status_changeset("running") |> Repo.update!()
1396 + notify_workflow_run(run, "in_progress")
1341 1397 end
1342 1398
1343 1399 update_job_commit_status(claimed, run)
@@ -2266,7 +2322,7 @@ defmodule GitGud.Workflows do
2266 2322 :ok
2267 2323
2268 2324 Enum.any?(jobs, &(effective_result(&1) == "failure")) ->
2269 − finish_run(run, "failure")
2325 + settle_run(run, "failure")
2270 2326
2271 2327 # A run whose jobs were cancelled only by newer jobs in their
2272 2328 # concurrency groups was superseded, not failed.
@@ -2274,15 +2330,43 @@ defmodule GitGud.Workflows do
2274 2330 superseded? =
2275 2331 jobs |> Enum.filter(&(&1.status == "cancelled")) |> Enum.all?(&superseded?/1)
2276 2332
2277 − finish_run(run, if(superseded?, do: "cancelled", else: "failure"))
2333 + settle_run(run, if(superseded?, do: "cancelled", else: "failure"))
2278 2334
2279 2335 true ->
2280 − run = finish_run(run, "success")
2281 − notify_run_webhook(run)
2282 − run
2336 + settle_run(run, "success")
2283 2337 end
2284 2338 end
2285 2339
2340 + # Every way a run finishes tells the `workflow_run` webhook and the
2341 + # `on: workflow_run` workflows — failed runs included, which the
2342 + # webhook used to miss.
2343 + defp settle_run(%WorkflowRun{} = run, status) do
2344 + run = finish_run(run, status)
2345 + notify_run_webhook(run)
2346 + notify_workflow_run(run, "completed")
2347 + run
2348 + end
2349 +
2350 + @doc """
2351 + The deepest `workflow_run` chain: a run this many hops from the event
2352 + that started it triggers no further `workflow_run` (GitHub's limit of
2353 + three levels).
2354 + """
2355 + def max_trigger_depth, do: @max_trigger_depth
2356 +
2357 + # Queue the `on: workflow_run` workflows for `run` reaching `action`
2358 + # (`requested`, `in_progress`, `completed`). Inserting the job joins the
2359 + # caller's transaction when there is one, so a rolled-back claim
2360 + # queues nothing.
2361 + defp notify_workflow_run(%WorkflowRun{trigger_depth: depth}, _action)
2362 + when is_integer(depth) and depth >= @max_trigger_depth,
2363 + do: :ok
2364 +
2365 + defp notify_workflow_run(%WorkflowRun{id: id}, action) do
2366 + {:ok, _job} = GitGud.Workflows.Workers.WorkflowRunDispatch.enqueue(id, action)
2367 + :ok
2368 + end
2369 +
2286 2370 defp superseded?(%WorkflowJob{status_reason: @superseded <> _}), do: true
2287 2371 defp superseded?(_job), do: false
2288 2372
@@ -2315,35 +2399,11 @@ defmodule GitGud.Workflows do
2315 2399 end
2316 2400
2317 2401 defp run_webhook_payload(%Repository{} = repo, %WorkflowRun{} = run) do
2318 − %{
2319 − "action" => "completed",
2320 − "workflow_run" => %{
2321 − "id" => run.id,
2322 − "run_number" => run.number,
2323 − "name" => run.workflow_name,
2324 − "path" => run.workflow_path,
2325 − "event" => run.trigger,
2326 − "status" => "completed",
2327 − "conclusion" => run.conclusion || run.status,
2328 − "head_sha" => run.head_sha && Git.to_hex(run.head_sha),
2329 − "head_branch" => head_branch(run.head_ref),
2330 − "html_url" =>
2331 − GitGudWeb.Endpoint.url() <>
2332 − "/r/repo/#{run.repository_id}/actions/runs/#{run.id}",
2333 − "run_started_at" => run.started_at,
2334 − "updated_at" => run.completed_at
2335 − },
2336 − "repository" => %{
2337 − "id" => repo.id,
2338 − "name" => repo.name,
2339 − "default_branch" => repo.default_branch
2340 − }
2341 − }
2402 + repo = Repo.preload(repo, [:owner, :organization])
2403 + sender = run.triggered_by_id && Repo.get(User, run.triggered_by_id)
2404 + EventPayload.workflow_run(repo, run, "completed", sender)
2342 2405 end
2343 2406
2344 − defp head_branch("refs/heads/" <> b), do: b
2345 − defp head_branch(other), do: other
2346 −
2347 2407 @doc """
2348 2408 Upsert the commit status for one job of a run. Runs on every job
2349 2409 transition, so the SCM views and branch protection see each job's
@@ -2529,6 +2589,7 @@ defmodule GitGud.Workflows do
2529 2589 pull_request_number: old.pull_request_number,
2530 2590 pr_head_ref: old.pr_head_ref,
2531 2591 pr_base_ref: old.pr_base_ref,
2592 + trigger_depth: old.trigger_depth,
2532 2593 jobs: doc.jobs
2533 2594 })
2534 2595 end
@@ -2733,6 +2794,7 @@ defmodule GitGud.Workflows do
2733 2794 do: release_job_group(%WorkflowJob{workflow_run_id: run.id, concurrency_group: group})
2734 2795
2735 2796 notify_run_webhook(updated)
2797 + notify_workflow_run(updated, "completed")
2736 2798 {:ok, updated}
2737 2799
2738 2800 err ->
modified lib/git_gud/workflows/event_payload.ex
+50 −0
@@ -137,6 +137,56 @@ defmodule GitGud.Workflows.EventPayload do
137 137 }
138 138 end
139 139
140 + @doc """
141 + A `workflow_run`: `run` (a `%WorkflowRun{}`) reached `action` —
142 + `"requested"`, `"in_progress"` or `"completed"`. Also the body of the
143 + `workflow_run` webhook.
144 + """
145 + def workflow_run(%Repository{} = repo, run, action, sender) do
146 + %{
147 + "action" => action,
148 + "workflow_run" => workflow_run_object(repo, run, action),
149 + "workflow" => %{
150 + "name" => run.workflow_name || run.workflow_path,
151 + "path" => run.workflow_path
152 + },
153 + "repository" => repository(repo),
154 + "sender" => sender(repo, sender)
155 + }
156 + end
157 +
158 + defp workflow_run_object(repo, run, action) do
159 + status =
160 + case action do
161 + "requested" -> "queued"
162 + other -> other
163 + end
164 +
165 + %{
166 + "id" => run.id,
167 + "run_number" => run.number,
168 + "name" => run.workflow_name || run.workflow_path,
169 + "path" => run.workflow_path,
170 + "event" => run.trigger,
171 + "status" => status,
172 + "conclusion" => if(action == "completed", do: run.conclusion || run.status),
173 + "head_sha" => sha_hex(run.head_sha),
174 + "head_branch" => run_head_branch(run),
175 + "html_url" => repo_url(repo) <> "/actions/#{run.number}",
176 + "run_attempt" => 1,
177 + "run_started_at" => iso8601(run.started_at),
178 + "created_at" => iso8601(run.inserted_at),
179 + "updated_at" => iso8601(run.completed_at || run.updated_at)
180 + }
181 + end
182 +
183 + @doc """
184 + A run's `head_branch`, as GitHub reports it: the branch (or tag) name,
185 + and a pull request run's source branch.
186 + """
187 + def run_head_branch(%{trigger: "pull_request", pr_head_ref: b}) when is_binary(b), do: b
188 + def run_head_branch(%{head_ref: ref}), do: Ref.name(ref)
189 +
140 190 @doc "A `schedule`: the cron that fired. Nobody triggered it, so `sender` is the owner."
141 191 def schedule(%Repository{} = repo, cron) do
142 192 %{
added lib/git_gud/workflows/workers/workflow_run_dispatch.ex
+34 −0
@@ -0,0 +1,34 @@
1 +defmodule GitGud.Workflows.Workers.WorkflowRunDispatch do
2 + @moduledoc """
3 + Starts the `on: workflow_run` workflows for a run that was requested,
4 + started or completed (`GitGud.Workflows.dispatch_workflow_run/2`), off
5 + the path that changed the run's status — a runner's claim or update
6 + shouldn't wait on reading the default branch's workflow files.
7 +
8 + One attempt: a retry after a partial dispatch would start the runs
9 + that did get created a second time.
10 + """
11 +
12 + use Oban.Worker, queue: :ci, max_attempts: 1
13 +
14 + alias GitGud.Repo
15 + alias GitGud.Workflows
16 + alias GitGud.Workflows.WorkflowRun
17 +
18 + @doc "Queue the dispatch for run `run_id` reaching `action`."
19 + def enqueue(run_id, action) when is_integer(run_id) and is_binary(action) do
20 + %{"run_id" => run_id, "action" => action} |> new() |> Oban.insert()
21 + end
22 +
23 + @impl Oban.Worker
24 + def perform(%Oban.Job{args: %{"run_id" => id, "action" => action}}) do
25 + case Repo.get(WorkflowRun, id) do
26 + %WorkflowRun{} = run ->
27 + {:ok, _runs} = Workflows.dispatch_workflow_run(run, action)
28 + :ok
29 +
30 + nil ->
31 + :ok
32 + end
33 + end
34 +end
modified lib/git_gud/workflows/workflow_run.ex
+6 −1
@@ -37,6 +37,10 @@ defmodule GitGud.Workflows.WorkflowRun do
37 37 field :concurrency_group, :string
38 38 field :cancel_in_progress, :boolean, default: false
39 39
40 + # `workflow_run` hops that led here: 0 unless a `workflow_run` started
41 + # it (`GitGud.Workflows.Workers.WorkflowRunDispatch`).
42 + field :trigger_depth, :integer, default: 0
43 +
40 44 belongs_to :repository, Repository
41 45 # The user who triggered the run (the pusher), when attributable.
42 46 # nil for runs we can't attribute (e.g. anonymous HTTP, re-runs).
@@ -60,7 +64,8 @@ defmodule GitGud.Workflows.WorkflowRun do
60 64 :triggered_by_id,
61 65 :pull_request_number,
62 66 :pr_head_ref,
63 − :pr_base_ref
67 + :pr_base_ref,
68 + :trigger_depth
64 69 ])
65 70 |> validate_required([:number, :workflow_path, :trigger, :head_sha])
66 71 end
modified lib/git_gud/workflows/yaml.ex
+29 −4
@@ -112,7 +112,11 @@ defmodule GitGud.Workflows.Yaml do
112 112 the list (with a warning). For `pull_request`, `branches` filters the
113 113 **target** branch (`ctx[:branch]`) and tag filters don't apply.
114 114 * `pull_request`'s `types:` (default `opened`, `synchronize`,
115 − `reopened`) must contain `ctx[:action]`.
115 + `reopened`) must contain `ctx[:action]`; so must `workflow_run`'s
116 + (default `completed`, `requested`).
117 + * `workflow_run`'s `workflows:` must contain `ctx[:workflow]`, the
118 + triggering run's workflow name, and its `branches:` filter that
119 + run's head branch (`ctx[:branch]`).
116 120 * `paths:` / `paths-ignore:` are checked against `ctx[:changed_paths]`
117 121 once the ref filter has passed; see `paths_match?/3`'s comment.
118 122 * All of them take `GitGud.Workflows.Glob` patterns (`*` stops at `/`).
@@ -129,14 +133,22 @@ defmodule GitGud.Workflows.Yaml do
129 133 false
130 134
131 135 {:ok, %{} = config} ->
132 − type_match?(event, config, ctx) and ref_match?(event, config, ctx, doc) and
133 − paths_match?(config, ctx, doc)
136 + type_match?(event, config, ctx) and workflows_match?(event, config, ctx) and
137 + ref_match?(event, config, ctx, doc) and paths_match?(config, ctx, doc)
134 138
135 139 {:ok, _no_filters} ->
136 − type_match?(event, %{}, ctx)
140 + type_match?(event, %{}, ctx) and workflows_match?(event, %{}, ctx)
137 141 end
138 142 end
139 143
144 + # `workflow_run` must name the workflows it follows (GitHub refuses the
145 + # file otherwise), by their `name:`. Without the list it follows none.
146 + defp workflows_match?("workflow_run", config, %{workflow: name}) when is_binary(name),
147 + do: name in patterns(Map.get(config, "workflows"))
148 +
149 + defp workflows_match?("workflow_run", _config, _ctx), do: false
150 + defp workflows_match?(_event, _config, _ctx), do: true
151 +
140 152 @pull_request_types ~w(opened synchronize reopened)
141 153
142 154 # GitHub's default `types` for `pull_request`. `closed`, `edited`,
@@ -147,6 +159,15 @@ defmodule GitGud.Workflows.Yaml do
147 159 action in Enum.map(types, &to_string/1)
148 160 end
149 161
162 + # `workflow_run`'s default `types`, as on GitHub: `in_progress` only when
163 + # asked for.
164 + @workflow_run_types ~w(completed requested)
165 +
166 + defp type_match?("workflow_run", config, %{action: action}) when is_binary(action) do
167 + types = config |> Map.get("types", @workflow_run_types) |> List.wrap()
168 + action in Enum.map(types, &to_string/1)
169 + end
170 +
150 171 defp type_match?(_event, _config, _ctx), do: true
151 172
152 173 # A pull request is filtered on its target branch; `tags:` has nothing to
@@ -154,6 +175,10 @@ defmodule GitGud.Workflows.Yaml do
154 175 defp ref_match?("pull_request", config, %{branch: branch}, doc) when is_binary(branch),
155 176 do: kind_match?(config, {"branches", "branches-ignore"}, [], branch, doc)
156 177
178 + # `workflow_run`'s `branches:` filter the triggering run's head branch.
179 + defp ref_match?("workflow_run", config, %{branch: branch}, doc) when is_binary(branch),
180 + do: kind_match?(config, {"branches", "branches-ignore"}, [], branch, doc)
181 +
157 182 # A push is to a branch or to a tag, never both, so the two filters are one
158 183 # decision rather than two independent ones.
159 184 #
added priv/repo/migrations/20261017180548_add_trigger_depth_to_workflow_runs.exs
+11 −0
@@ -0,0 +1,11 @@
1 +defmodule GitGud.Repo.Migrations.AddTriggerDepthToWorkflowRuns do
2 + use Ecto.Migration
3 +
4 + def change do
5 + alter table(:workflow_runs) do
6 + # How many `workflow_run` hops led to this run: 0 for one started by
7 + # anything else. A run at depth 3 triggers no further workflow_run.
8 + add :trigger_depth, :integer, null: false, default: 0
9 + end
10 + end
11 +end
added test/git_gud/workflows/workflow_run_dispatch_test.exs
+218 −0
@@ -0,0 +1,218 @@
1 +defmodule GitGud.Workflows.WorkflowRunDispatchTest do
2 + @moduledoc """
3 + `on: workflow_run`, and the `workflow_run` webhook, for every way a run
4 + finishes.
5 + """
6 + use GitGud.DataCase, async: false
7 + use Oban.Testing, repo: GitGud.Repo
8 +
9 + alias GitGud.Repositories.Repository
10 + alias GitGud.Webhooks
11 + alias GitGud.Webhooks.Workers.DeliverWebhook
12 + alias GitGud.Workflows
13 + alias GitGud.Workflows.WorkflowJob
14 + alias GitGud.Workflows.WorkflowRun
15 +
16 + import GitGud.ForgeFixtures
17 + import GitGud.WorkflowGitHelpers
18 +
19 + @moduletag :capture_log
20 +
21 + @ci """
22 + name: ci
23 + on: push
24 + jobs:
25 + build:
26 + runs-on: self-hosted
27 + steps:
28 + - run: echo build
29 + """
30 +
31 + @lint """
32 + name: lint
33 + on: push
34 + jobs:
35 + lint:
36 + runs-on: self-hosted
37 + steps:
38 + - run: echo lint
39 + """
40 +
41 + defp deploy(on) do
42 + """
43 + name: deploy
44 + on:
45 + workflow_run:
46 + #{on}
47 + jobs:
48 + deploy:
49 + runs-on: self-hosted
50 + steps:
51 + - run: echo deploy
52 + """
53 + end
54 +
55 + @follow_ci " workflows: [ci]\n types: [completed]\n"
56 +
57 + setup do
58 + {user, repo} = repository_fixture()
59 + {:ok, repo: repo, user: user}
60 + end
61 +
62 + defp push_main!(repo, files) do
63 + head = commit!(repo, files, [])
64 + push_ref!(repo, "refs/heads/main", nil, head)
65 + head
66 + end
67 +
68 + defp runs(%Repository{id: rid}, trigger) do
69 + from(r in WorkflowRun,
70 + where: r.repository_id == ^rid and r.trigger == ^trigger,
71 + order_by: r.id
72 + )
73 + |> Repo.all()
74 + end
75 +
76 + defp run_named(repo, name), do: Enum.find(runs(repo, "push"), &(&1.workflow_name == name))
77 +
78 + # Every job of `run` to `status`, then the queued dispatches.
79 + defp finish!(%WorkflowRun{id: rid}, status) do
80 + for job <- Repo.all(from j in WorkflowJob, where: j.workflow_run_id == ^rid),
81 + do: {:ok, _} = Workflows.update_job_status(job, status)
82 +
83 + drain!()
84 + Repo.get!(WorkflowRun, rid)
85 + end
86 +
87 + defp drain!, do: Oban.drain_queue(queue: :ci)
88 +
89 + test "a ci run completing fires deploy, with the run in the payload", %{repo: repo} do
90 + head =
91 + push_main!(repo, %{
92 + ".forgejo/workflows/ci.yml" => @ci,
93 + ".forgejo/workflows/deploy.yml" => deploy(@follow_ci)
94 + })
95 +
96 + drain!()
97 + assert runs(repo, "workflow_run") == []
98 +
99 + ci = finish!(run_named(repo, "ci"), "success")
100 +
101 + assert [run] = runs(repo, "workflow_run")
102 + assert run.workflow_name == "deploy"
103 + assert run.head_ref == "refs/heads/main"
104 + assert run.head_sha == sha!(head)
105 + assert run.trigger_depth == 1
106 +
107 + assert %{"action" => "completed", "workflow_run" => parent} = run.event_payload
108 + assert parent["id"] == ci.id
109 + assert parent["name"] == "ci"
110 + assert parent["conclusion"] == "success"
111 + assert parent["head_branch"] == "main"
112 + assert parent["head_sha"] == head
113 + assert parent["event"] == "push"
114 + assert parent["run_number"] == ci.number
115 + end
116 +
117 + test "a failing ci run fires it too", %{repo: repo} do
118 + push_main!(repo, %{
119 + ".forgejo/workflows/ci.yml" => @ci,
120 + ".forgejo/workflows/deploy.yml" => deploy(@follow_ci)
121 + })
122 +
123 + finish!(run_named(repo, "ci"), "failure")
124 +
125 + assert [run] = runs(repo, "workflow_run")
126 + assert run.event_payload["workflow_run"]["conclusion"] == "failure"
127 + end
128 +
129 + test "a run of another workflow doesn't", %{repo: repo} do
130 + push_main!(repo, %{
131 + ".forgejo/workflows/lint.yml" => @lint,
132 + ".forgejo/workflows/deploy.yml" => deploy(@follow_ci)
133 + })
134 +
135 + finish!(run_named(repo, "lint"), "success")
136 + assert runs(repo, "workflow_run") == []
137 + end
138 +
139 + test "default types are requested and completed; branches: filters the run's branch", %{
140 + repo: repo
141 + } do
142 + push_main!(repo, %{
143 + ".forgejo/workflows/ci.yml" => @ci,
144 + ".forgejo/workflows/deploy.yml" => deploy(" workflows: [ci]\n branches: [main]\n")
145 + })
146 +
147 + drain!()
148 + assert [requested] = runs(repo, "workflow_run")
149 + assert requested.event_payload["action"] == "requested"
150 +
151 + finish!(run_named(repo, "ci"), "success")
152 +
153 + assert ["requested", "completed"] =
154 + Enum.map(runs(repo, "workflow_run"), & &1.event_payload["action"])
155 +
156 + # A ci run on another branch: filtered out.
157 + other = commit!(repo, %{".forgejo/workflows/ci.yml" => @ci}, [])
158 + push_ref!(repo, "refs/heads/feature", nil, other)
159 + drain!()
160 + assert length(runs(repo, "workflow_run")) == 2
161 + end
162 +
163 + test "a chain stops at depth 3", %{repo: repo} do
164 + # Each `deploy` run completing triggers deploy again.
165 + loop = deploy(" workflows: [ci, deploy]\n types: [completed]\n")
166 +
167 + push_main!(repo, %{
168 + ".forgejo/workflows/ci.yml" => @ci,
169 + ".forgejo/workflows/deploy.yml" => loop
170 + })
171 +
172 + finish!(run_named(repo, "ci"), "success")
173 +
174 + for depth <- 1..3 do
175 + assert [run] = Enum.filter(runs(repo, "workflow_run"), &(&1.trigger_depth == depth))
176 + if depth < 3, do: finish!(run, "success")
177 + end
178 +
179 + [deepest] = Enum.filter(runs(repo, "workflow_run"), &(&1.trigger_depth == 3))
180 + finish!(deepest, "success")
181 + assert Enum.map(runs(repo, "workflow_run"), & &1.trigger_depth) == [1, 2, 3]
182 + end
183 +
184 + describe "the workflow_run webhook" do
185 + setup %{repo: repo} do
186 + {:ok, _hook} =
187 + Webhooks.create_webhook(repo, %{
188 + "url" => "https://example.com/hook",
189 + "events" => ["workflow_run"]
190 + })
191 +
192 + push_main!(repo, %{".forgejo/workflows/ci.yml" => @ci})
193 + :ok
194 + end
195 +
196 + defp delivered do
197 + for %{args: args} <- all_enqueued(worker: DeliverWebhook) do
198 + {args["event"], args["payload"]}
199 + end
200 + end
201 +
202 + test "is delivered for a failed run", %{repo: repo} do
203 + run = finish!(run_named(repo, "ci"), "failure")
204 +
205 + assert [{"workflow_run", payload}] = delivered()
206 + assert payload["action"] == "completed"
207 + assert payload["workflow_run"]["id"] == run.id
208 + assert payload["workflow_run"]["conclusion"] == "failure"
209 + assert payload["workflow_run"]["html_url"] =~ "/actions/#{run.number}"
210 + refute payload["workflow_run"]["html_url"] =~ "/r/repo/"
211 + end
212 +
213 + test "and for a successful one", %{repo: repo} do
214 + finish!(run_named(repo, "ci"), "success")
215 + assert [{"workflow_run", %{"workflow_run" => %{"conclusion" => "success"}}}] = delivered()
216 + end
217 + end
218 +end

Parents: 8a80762