neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: on: workflow_run fires, and the webhook reports failed runs
a9bbffe · Gabriel Morell · 2026-10-17 18:13
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