Actions: decide a job's `if:` on the server when it can

1f1d2a3 · Gabriel Morell · 2026-10-31 05:14

10 files +482 -30
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/workflows.ex
+60 −13
@@ -24,6 +24,7 @@ defmodule GitGud.Workflows do
24 24 alias GitGud.Workflows.Context
25 25 alias GitGud.Workflows.DispatchInputs
26 26 alias GitGud.Workflows.EventPayload
27 + alias GitGud.Workflows.Expr
27 28 alias GitGud.Workflows.Matrix
28 29 alias GitGud.Workflows.Ref
29 30 alias GitGud.Workflows.Runner
@@ -917,6 +918,11 @@ defmodule GitGud.Workflows do
917 918 for leg <- legs, do: write_job_commit_status(leg, run, repo)
918 919 drop_legacy_commit_status(run)
919 920
921 + # Before any job is queued: one whose `if:` is false is skipped as
922 + # it is, and a run with nothing else to do is completed (and says
923 + # so) right here, which must not read as preceding its request.
924 + notify_workflow_run(run, "requested")
925 +
920 926 for %WorkflowJob{status: "queued"} = leg <- legs, do: enqueue_job(leg)
921 927
922 928 {run, hold, bad_matrices}
@@ -924,7 +930,6 @@ defmodule GitGud.Workflows do
924 930 |> case do
925 931 {:ok, {run, hold, bad_matrices}} ->
926 932 broadcast_ci_change(run.id)
927 − notify_workflow_run(run, "requested")
928 933
929 934 for {row, reason} <- bad_matrices, do: fail_matrix(row, reason)
930 935
@@ -2194,6 +2199,9 @@ defmodule GitGud.Workflows do
2194 2199 # `awaiting_approval`, and `approve_pull_request_runs/2` brings it back
2195 2200 # here. Every way a job is queued passes this point, which is what keeps
2196 2201 # an unapproved fork's code off the runners.
2202 + #
2203 + # A job whose `if:` is false is skipped here instead of queued
2204 + # (`condition_false?/2`). Returns the row as it now stands.
2197 2205 defp enqueue_job(%WorkflowJob{} = job, changes \\ []) do
2198 2206 run = Repo.get!(WorkflowRun, job.workflow_run_id)
2199 2207
@@ -2212,6 +2220,45 @@ defmodule GitGud.Workflows do
2212 2220 Context.for_job(next, run, Repositories.get_repository!(run.repository_id))
2213 2221 end
2214 2222
2223 + if condition_false?(next, contexts),
2224 + do: skip_by_condition(job, changes),
2225 + else: enqueue_in_group(job, next, run, changes, contexts)
2226 + end
2227 +
2228 + # The `status_reason` of a job skipped because its `if:` is false.
2229 + @if_false "Skipped: `if:` is false"
2230 +
2231 + # Whether the job's `if:` is false, as far as the forge can tell. The
2232 + # runner would decide it anyway once it picked the job up — but only a
2233 + # runner carrying its `runs-on` labels picks it up, and a job that no
2234 + # connected runner matches (a `macos-latest` leg of a release matrix
2235 + # on a push to a branch) would sit queued for good, holding its run,
2236 + # and the run's concurrency group, open behind it.
2237 + #
2238 + # Only a definite false counts. An `if:` that reads what only the
2239 + # runner has (`steps`, `env`, `secrets`, `runner`), or calls a status
2240 + # function (`always()`, `success()`, …: `Expr` evaluates none of them),
2241 + # is `:error`, and the runner decides it as before. GitHub's implicit
2242 + # `success() &&` holds by now: a job is only queued with a failed need
2243 + # when its `if:` calls a status function.
2244 + defp condition_false?(%WorkflowJob{definition: %{"if" => condition}}, contexts)
2245 + when not is_nil(condition),
2246 + do: Expr.condition(condition, contexts.()) == {:ok, false}
2247 +
2248 + defp condition_false?(_job, _contexts), do: false
2249 +
2250 + # Skipped as though the runner had reported it, so its dependents and
2251 + # its run are settled the same way (`update_job_status/3`).
2252 + defp skip_by_condition(%WorkflowJob{} = job, changes) do
2253 + row = job |> Ecto.Changeset.change(changes) |> Repo.update!()
2254 +
2255 + case update_job_status(row, "skipped", reason: @if_false) do
2256 + {:ok, skipped} -> skipped
2257 + {:error, _refused} -> Repo.get!(WorkflowJob, job.id)
2258 + end
2259 + end
2260 +
2261 + defp enqueue_in_group(%WorkflowJob{} = job, next, %WorkflowRun{} = run, changes, contexts) do
2215 2262 case Concurrency.evaluate(next.definition["concurrency"], contexts) do
2216 2263 :none when job.status == "queued" and changes == [] ->
2217 2264 job
@@ -2381,7 +2428,8 @@ defmodule GitGud.Workflows do
2381 2428
2382 2429 * a need that doesn't exist → `cancelled` (author error)
2383 2430 * every need concluded `success` (`effective_result/1`) → `queued`
2384 − (or `waiting`, by its `concurrency:` group)
2431 + (or `waiting`, by its `concurrency:` group), unless its `if:` is
2432 + plainly false (`enqueue_job/2`), which skips it
2385 2433 * otherwise, the job's `if:` calls `always()`, `failure()` or
2386 2434 `cancelled()` → `queued`, and the runner evaluates the `if:`
2387 2435 against the real `needs` results
@@ -3041,13 +3089,11 @@ defmodule GitGud.Workflows do
3041 3089 )
3042 3090 |> Repo.update()
3043 3091
3044 − # A matrix placeholder is expanded once this is committed,
3045 − # below: expanding inside it could fail the job, and settle
3046 − # the run, before the run is flipped back to `running`.
3047 − case not placeholder?(row) && verdict.(job) do
3048 − {:queue, snapshot} -> enqueue_job(row, needs_snapshot: snapshot)
3049 − _ -> row
3050 − end
3092 + # Queued (or its matrix expanded) once this is committed,
3093 + # below: queueing it inside could skip it by its `if:`, or
3094 + # fail its matrix, and settle the run before the run is
3095 + # flipped back to `running` and before every leg is reset.
3096 + row
3051 3097 end)
3052 3098
3053 3099 # Flip the run non-terminal so it isn't "complete" and the
@@ -3061,10 +3107,11 @@ defmodule GitGud.Workflows do
3061 3107 Enum.each(updated, &broadcast({:job_status, run.id, &1}))
3062 3108 Enum.each(updated, &update_job_commit_status(&1, run))
3063 3109
3064 − # A job whose needs are all done but wouldn't let it run is
3065 − # skipped (or cancelled) again rather than left blocked on
3066 − # upstreams that won't finish twice, which would hold the run
3067 − # open for good.
3110 + # Each leg now goes the way `resolve_dependents/1` would send
3111 + # it: queued when its needs allow, and a job whose needs are
3112 + # all done but wouldn't let it run is skipped (or cancelled)
3113 + # again rather than left blocked on upstreams that won't
3114 + # finish twice, which would hold the run open for good.
3068 3115 Enum.each(updated, fn
3069 3116 %WorkflowJob{status: "blocked"} = row ->
3070 3117 case verdict.(row) do
modified lib/git_gud/workflows/expr.ex
+108 −14
@@ -4,24 +4,32 @@ defmodule GitGud.Workflows.Expr do
4 4
5 5 Almost everything in a workflow is the runner's to evaluate. The
6 6 exception is what decides things before any runner is involved —
7 − `concurrency:` groups — and those are nearly always built from a few
8 − context values: `${{ github.workflow }}-${{ github.ref }}`,
7 + `concurrency:` groups, and a job's own `if:` (`condition/2`), which
8 + has to be decided before the job is queued: left to the runner, a job
9 + whose `if:` is false but whose `runs-on` no runner carries is never
10 + picked up, and so never skipped. Those are nearly always built from a
11 + few context values: `${{ github.workflow }}-${{ github.ref }}`,
9 12 `${{ github.event.pull_request.number || github.ref }}`,
10 − `${{ matrix.target }}` — and a `strategy.matrix` computed by an earlier
11 − job, `${{ fromJSON(needs.setup.outputs.matrix) }}`. So this covers that
12 − and no more:
13 + `${{ matrix.target }}`, `startsWith(github.ref, 'refs/tags/v')` — and
14 + a `strategy.matrix` computed by an earlier job, `${{
15 + fromJSON(needs.setup.outputs.matrix) }}`. So this covers that and no
16 + more:
13 17
14 18 * property paths into `contexts` — `github.ref`, `inputs['env']`,
15 19 `needs.build.outputs.x` — case-insensitively, as GitHub does; a path
16 20 through something that isn't there is `null`
17 21 * `'string'` (`''` escapes a quote), number, `true`, `false`, `null`
18 − * `||`, `&&` (returning an operand, not a boolean), `==`, `!=`, `!`,
19 − parentheses
20 − * `format('{0}-{1}', …)`, `fromJSON(string)`, `toJSON(value)`
21 −
22 − Anything else — another function, a comparison, `*` filters, a context
23 − that wasn't supplied — is `:error`, and the caller acts as though the
24 − expression weren't there rather than on a guess.
22 + * `||`, `&&` (returning an operand, not a boolean), `==`, `!=`, `<`,
23 + `<=`, `>`, `>=`, `!`, parentheses
24 + * `format('{0}-{1}', …)`, `fromJSON(string)`, `toJSON(value)`,
25 + `startsWith`, `endsWith`, `contains` (strings ignoring case, as
26 + GitHub does; `contains` also finds an element of an array)
27 +
28 + Anything else — another function (the status functions `success()`,
29 + `always()`, `failure()`, `cancelled()` among them), `*` filters, a
30 + context that wasn't supplied (`steps`, `env`, `secrets`, `runner`) — is
31 + `:error`, and the caller acts as though the expression weren't there
32 + rather than on a guess.
25 33
26 34 `contexts` is a map of string-keyed maps: `%{"github" => %{…}, "matrix"
27 35 => %{…}}`.
@@ -83,6 +91,39 @@ defmodule GitGud.Workflows.Expr do
83 91 :error -> :error
84 92 end
85 93
94 + @doc """
95 + What a job's `if:` decides, as a boolean: written with or without the
96 + `${{ }}` around it, as GitHub allows, or a YAML boolean. `:error` for
97 + anything that isn't one whole expression (`${{ a }} == 'b'` is a
98 + string to GitHub, and so always true — not what its author meant, and
99 + not ours to settle) or that `evaluate/2` can't evaluate.
100 + """
101 + @spec condition(term(), map()) :: {:ok, boolean()} | :error
102 + def condition(condition, _contexts) when is_boolean(condition), do: {:ok, condition}
103 +
104 + def condition(condition, contexts) when is_binary(condition) do
105 + with expression when is_binary(expression) <- unwrap(String.trim(condition)),
106 + false <- String.trim(expression) == "",
107 + {:ok, value} <- evaluate(expression, contexts) do
108 + {:ok, truthy?(value)}
109 + else
110 + _ -> :error
111 + end
112 + end
113 +
114 + def condition(_condition, _contexts), do: :error
115 +
116 + # The expression inside a `${{ }}` that is the whole string; the string
117 + # itself when it has none.
118 + defp unwrap("${{" <> after_open) do
119 + case take_whole_expression(after_open) do
120 + {expression, rest} -> if String.trim(rest) == "", do: expression, else: :error
121 + :error -> :error
122 + end
123 + end
124 +
125 + defp unwrap(string), do: if(String.contains?(string, "${{"), do: :error, else: string)
126 +
86 127 @doc "GitHub's truthiness: `false`, `0`, `\"\"` and `null` are false."
87 128 def truthy?(value), do: value not in [false, nil, 0, +0.0, -0.0, ""]
88 129
@@ -142,6 +183,10 @@ defmodule GitGud.Workflows.Expr do
142 183 defp tokenize("&&" <> rest, acc), do: tokenize(rest, [:and | acc])
143 184 defp tokenize("==" <> rest, acc), do: tokenize(rest, [:eq | acc])
144 185 defp tokenize("!=" <> rest, acc), do: tokenize(rest, [:neq | acc])
186 + defp tokenize("<=" <> rest, acc), do: tokenize(rest, [:le | acc])
187 + defp tokenize(">=" <> rest, acc), do: tokenize(rest, [:ge | acc])
188 + defp tokenize("<" <> rest, acc), do: tokenize(rest, [:lt | acc])
189 + defp tokenize(">" <> rest, acc), do: tokenize(rest, [:gt | acc])
145 190 defp tokenize("!" <> rest, acc), do: tokenize(rest, [:not | acc])
146 191 defp tokenize("(" <> rest, acc), do: tokenize(rest, [:lparen | acc])
147 192 defp tokenize(")" <> rest, acc), do: tokenize(rest, [:rparen | acc])
@@ -226,18 +271,31 @@ defmodule GitGud.Workflows.Expr do
226 271 defp parse_and_rest(left, rest, _contexts), do: {left, rest}
227 272
228 273 defp parse_equality(tokens, contexts) do
229 − {left, rest} = parse_unary(tokens, contexts)
274 + {left, rest} = parse_comparison(tokens, contexts)
230 275 parse_equality_rest(left, rest, contexts)
231 276 end
232 277
233 278 defp parse_equality_rest(left, [op | rest], contexts) when op in [:eq, :neq] do
234 − {right, rest} = parse_unary(rest, contexts)
279 + {right, rest} = parse_comparison(rest, contexts)
235 280 equal = equal?(left, right)
236 281 parse_equality_rest(if(op == :eq, do: equal, else: not equal), rest, contexts)
237 282 end
238 283
239 284 defp parse_equality_rest(left, rest, _contexts), do: {left, rest}
240 285
286 + # `<` and friends bind tighter than `==`, as in GitHub's grammar.
287 + defp parse_comparison(tokens, contexts) do
288 + {left, rest} = parse_unary(tokens, contexts)
289 + parse_comparison_rest(left, rest, contexts)
290 + end
291 +
292 + defp parse_comparison_rest(left, [op | rest], contexts) when op in [:lt, :le, :gt, :ge] do
293 + {right, rest} = parse_unary(rest, contexts)
294 + parse_comparison_rest(compare(op, left, right), rest, contexts)
295 + end
296 +
297 + defp parse_comparison_rest(left, rest, _contexts), do: {left, rest}
298 +
241 299 defp parse_unary([:not | rest], contexts) do
242 300 {value, rest} = parse_unary(rest, contexts)
243 301 {not truthy?(value), rest}
@@ -313,6 +371,20 @@ defmodule GitGud.Workflows.Expr do
313 371
314 372 defp call("tojson", [value]), do: Jason.encode!(value, pretty: true)
315 373
374 + # GitHub casts both sides to strings and ignores case.
375 + defp call("startswith", [string, prefix]),
376 + do: String.starts_with?(fold(string), fold(prefix))
377 +
378 + defp call("endswith", [string, suffix]),
379 + do: String.ends_with?(fold(string), fold(suffix))
380 +
381 + # An array holds the item when an element equals it (loosely, as
382 + # `==`); anything else is searched as a string.
383 + defp call("contains", [list, item]) when is_list(list),
384 + do: Enum.any?(list, &equal?(&1, item))
385 +
386 + defp call("contains", [string, item]), do: String.contains?(fold(string), fold(item))
387 +
316 388 defp call(_function, _args), do: throw(:error)
317 389
318 390 defp format("", _args, acc), do: acc |> Enum.reverse() |> IO.iodata_to_binary()
@@ -388,6 +460,28 @@ defmodule GitGud.Workflows.Expr do
388 460 end
389 461 end
390 462
463 + # GitHub's ordering: two strings compare ignoring case; anything else
464 + # compares as numbers, and NaN is neither less nor greater than
465 + # anything.
466 + defp compare(op, a, b) when is_binary(a) and is_binary(b),
467 + do: ordered?(op, String.downcase(a), String.downcase(b))
468 +
469 + defp compare(_op, a, b) when is_map(a) or is_list(a) or is_map(b) or is_list(b), do: false
470 +
471 + defp compare(op, a, b) do
472 + case {to_number(a), to_number(b)} do
473 + {x, y} when is_number(x) and is_number(y) -> ordered?(op, x, y)
474 + _ -> false
475 + end
476 + end
477 +
478 + defp ordered?(:lt, a, b), do: a < b
479 + defp ordered?(:le, a, b), do: a <= b
480 + defp ordered?(:gt, a, b), do: a > b
481 + defp ordered?(:ge, a, b), do: a >= b
482 +
483 + defp fold(value), do: value |> stringify() |> String.downcase()
484 +
391 485 defp to_number(nil), do: 0
392 486 defp to_number(true), do: 1
393 487 defp to_number(false), do: 0
modified test/git_gud/workflows/expr_test.exs
+83 −3
@@ -94,8 +94,13 @@ defmodule GitGud.Workflows.ExprTest do
94 94
95 95 describe "what isn't supported is an error, not a guess" do
96 96 test "other functions" do
97 − assert i("${{ startsWith(github.ref, 'refs/') }}") == :error
98 − assert i("${{ contains(github.ref, 'main') }}") == :error
97 + assert i("${{ hashFiles('**/Cargo.lock') }}") == :error
98 + assert i("${{ join(matrix, ',') }}") == :error
99 + end
100 +
101 + test "the status functions" do
102 + for f <- ~w(success always failure cancelled),
103 + do: assert(Expr.evaluate("#{f}()", @contexts) == :error)
99 104 end
100 105
101 106 test "a context nobody supplied" do
@@ -104,7 +109,7 @@ defmodule GitGud.Workflows.ExprTest do
104 109
105 110 test "unknown syntax" do
106 111 assert i("${{ github.* }}") == :error
107 − assert i("${{ github.ref < 'x' }}") == :error
112 + assert i("${{ github.ref <> 'x' }}") == :error
108 113 assert i("${{ \"double\" }}") == :error
109 114 assert i("${{ github.ref") == :error
110 115 end
@@ -142,4 +147,79 @@ defmodule GitGud.Workflows.ExprTest do
142 147 assert r("${{ format('{{0}}') }}") == {:ok, "{0}"}
143 148 end
144 149 end
150 +
151 + defp e(expression), do: Expr.evaluate(expression, @contexts)
152 +
153 + describe "what a job's `if:` commonly reads" do
154 + test "startsWith and endsWith ignore case, casting to strings" do
155 + assert e("startsWith(github.ref, 'refs/heads/')") == {:ok, true}
156 + assert e("startsWith(github.ref, 'REFS/HEADS/')") == {:ok, true}
157 + assert e("startsWith(github.ref, 'refs/tags/v')") == {:ok, false}
158 + assert e("endsWith(github.ref, '/MAIN')") == {:ok, true}
159 + assert e("startsWith(matrix.n, 2)") == {:ok, true}
160 + assert e("startsWith(github.nothing, '')") == {:ok, true}
161 + end
162 +
163 + test "contains: a substring ignoring case, or an element of an array" do
164 + assert e("contains(github.ref, 'MAIN')") == {:ok, true}
165 + assert e("contains(github.ref, 'dev')") == {:ok, false}
166 + assert e("contains(fromJSON('[\"push\", \"Schedule\"]'), 'schedule')") == {:ok, true}
167 + assert e("contains(fromJSON('[1, 2]'), '2')") == {:ok, true}
168 + assert e("contains(fromJSON('[1, 2]'), 3)") == {:ok, false}
169 + end
170 +
171 + test "an array or object where a string was wanted is an error" do
172 + assert e("startsWith(github.event, 'x')") == :error
173 + end
174 +
175 + test "<, <=, >, >= compare numbers, and strings ignoring case" do
176 + assert e("matrix.n > 1") == {:ok, true}
177 + assert e("matrix.n >= 2") == {:ok, true}
178 + assert e("matrix.n < 2") == {:ok, false}
179 + assert e("matrix.n <= '2'") == {:ok, true}
180 + assert e("'a' < 'B'") == {:ok, true}
181 + assert e("'x' > 1") == {:ok, false}
182 + assert e("'x' < 1") == {:ok, false}
183 + end
184 +
185 + test "comparisons bind tighter than ==" do
186 + assert e("matrix.n > 1 == true") == {:ok, true}
187 + assert e("1 < 2 && 3 >= 4") == {:ok, false}
188 + end
189 + end
190 +
191 + describe "condition/2" do
192 + test "is the expression's truthiness, with or without ${{ }}" do
193 + assert Expr.condition("startsWith(github.ref, 'refs/tags/v')", @contexts) == {:ok, false}
194 +
195 + assert Expr.condition("${{ startsWith(github.ref, 'refs/tags/v') }}", @contexts) ==
196 + {:ok, false}
197 +
198 + assert Expr.condition(" ${{ github.ref == 'refs/heads/main' }} ", @contexts) == {:ok, true}
199 + assert Expr.condition("github.head_ref", @contexts) == {:ok, false}
200 + assert Expr.condition("matrix.n", @contexts) == {:ok, true}
201 + assert Expr.condition("0", @contexts) == {:ok, false}
202 + assert Expr.condition("null", @contexts) == {:ok, false}
203 + end
204 +
205 + test "a YAML boolean is itself" do
206 + assert Expr.condition(false, @contexts) == {:ok, false}
207 + assert Expr.condition(true, @contexts) == {:ok, true}
208 + end
209 +
210 + test "is an error for what only the runner can decide" do
211 + assert Expr.condition("steps.x.outputs.y == 'z'", @contexts) == :error
212 + assert Expr.condition("always()", @contexts) == :error
213 + assert Expr.condition("${{ success() && github.ref == 'x' }}", @contexts) == :error
214 + assert Expr.condition("false && always()", @contexts) == :error
215 + assert Expr.condition("env.DEPLOY == 'true'", @contexts) == :error
216 + end
217 +
218 + test "is an error for anything but one whole expression" do
219 + assert Expr.condition("${{ github.ref }} == 'x'", @contexts) == :error
220 + assert Expr.condition("", @contexts) == :error
221 + assert Expr.condition("${{ }}", @contexts) == :error
222 + assert Expr.condition(1, @contexts) == :error
223 + end
224 + end
145 225 end
added test/git_gud/workflows/job_if_test.exs
+231 −0
@@ -0,0 +1,231 @@
1 +defmodule GitGud.Workflows.JobIfTest do
2 + @moduledoc """
3 + A job's `if:`, decided by the forge as the job would be queued, when
4 + it can be: a false one skips the job (and so its dependents, and maybe
5 + finishes the run) instead of leaving it queued for a runner that may
6 + never come — a `macos-latest` leg of a release matrix on a branch push,
7 + say. Anything the forge can't evaluate is queued for the runner, as
8 + before.
9 + """
10 + use GitGud.DataCase, async: false
11 +
12 + alias GitGud.Workflows
13 + alias GitGud.Workflows.WorkflowJob
14 + alias GitGud.Workflows.WorkflowRun
15 + alias GitGud.Workflows.Yaml
16 +
17 + import GitGud.ForgeFixtures
18 +
19 + setup do
20 + {_user, repo} = repository_fixture()
21 + {:ok, repo: repo}
22 + end
23 +
24 + # As in JobGraphTest: a seed row plus a re-run creates the jobs through
25 + # the real path without a repository on disk.
26 + defp run_workflow(repo, yaml, ref) do
27 + {:ok, doc} = Yaml.parse(yaml)
28 +
29 + seed =
30 + %WorkflowRun{repository_id: repo.id}
31 + |> WorkflowRun.create_changeset(%{
32 + "number" => System.unique_integer([:positive]),
33 + "workflow_path" => ".forgejo/workflows/ci.yml",
34 + "workflow_name" => doc.name,
35 + "trigger" => "push",
36 + "head_ref" => ref,
37 + "head_sha" => :crypto.strong_rand_bytes(20),
38 + "workflow_doc" => doc.raw
39 + })
40 + |> Repo.insert!()
41 +
42 + {:ok, run} = Workflows.rerun(seed)
43 + run
44 + end
45 +
46 + defp jobs(run, job_id) do
47 + Repo.all(
48 + from j in WorkflowJob,
49 + where: j.workflow_run_id == ^run.id and j.job_id == ^job_id,
50 + order_by: j.id
51 + )
52 + end
53 +
54 + defp job(run, job_id), do: run |> jobs(job_id) |> hd()
55 + defp run_status(run), do: Repo.get!(WorkflowRun, run.id).status
56 +
57 + # The shape of neiam/git-friends' rust-ci.yml: a release matrix across
58 + # runners nobody here carries, only for tags.
59 + @release """
60 + name: ci
61 + on: push
62 + jobs:
63 + build:
64 + runs-on: self-hosted
65 + steps:
66 + - run: true
67 + release:
68 + needs: build
69 + if: startsWith(github.ref, 'refs/tags/v')
70 + strategy:
71 + matrix:
72 + include:
73 + - runner: ubuntu-latest
74 + - runner: ubuntu-24.04-arm
75 + - runner: macos-latest
76 + - runner: windows-latest
77 + runs-on: ${{ matrix.runner }}
78 + steps:
79 + - run: true
80 + create-release:
81 + needs: release
82 + if: startsWith(github.ref, 'refs/tags/v')
83 + runs-on: self-hosted
84 + steps:
85 + - run: true
86 + """
87 +
88 + describe "a release matrix only for tags" do
89 + test "on a branch push, every leg and its dependent is skipped, and the run succeeds",
90 + %{repo: repo} do
91 + run = run_workflow(repo, @release, "refs/heads/master")
92 + assert Enum.all?(jobs(run, "release"), &(&1.status == "blocked"))
93 +
94 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "success")
95 +
96 + legs = jobs(run, "release")
97 + assert length(legs) == 4
98 + assert Enum.all?(legs, &(&1.status == "skipped"))
99 + assert Enum.all?(legs, &(&1.status_reason == "Skipped: `if:` is false"))
100 + assert job(run, "create-release").status == "skipped"
101 + assert run_status(run) == "success"
102 + end
103 +
104 + test "on a tag push, the legs are queued", %{repo: repo} do
105 + run = run_workflow(repo, @release, "refs/tags/v1.2.0")
106 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "success")
107 +
108 + assert Enum.all?(jobs(run, "release"), &(&1.status == "queued"))
109 + assert job(run, "create-release").status == "blocked"
110 + assert run_status(run) in ~w(queued running)
111 + end
112 + end
113 +
114 + test "a job without needs is skipped as the run is created", %{repo: repo} do
115 + run =
116 + run_workflow(
117 + repo,
118 + """
119 + name: ci
120 + on: push
121 + jobs:
122 + publish:
123 + if: ${{ startsWith(github.ref, 'refs/tags/v') }}
124 + strategy:
125 + matrix:
126 + os: [macos-latest, windows-latest]
127 + runs-on: ${{ matrix.os }}
128 + steps:
129 + - run: true
130 + """,
131 + "refs/heads/main"
132 + )
133 +
134 + assert Enum.all?(jobs(run, "publish"), &(&1.status == "skipped"))
135 + assert run_status(run) == "success"
136 + end
137 +
138 + test "a matrix value can decide a leg", %{repo: repo} do
139 + run =
140 + run_workflow(
141 + repo,
142 + """
143 + name: ci
144 + on: push
145 + jobs:
146 + test:
147 + if: matrix.os != 'macos-latest'
148 + strategy:
149 + matrix:
150 + os: [self-hosted, macos-latest]
151 + runs-on: ${{ matrix.os }}
152 + steps:
153 + - run: true
154 + """,
155 + "refs/heads/main"
156 + )
157 +
158 + assert %{"self-hosted" => "queued", "macos-latest" => "skipped"} ==
159 + Map.new(jobs(run, "test"), &{&1.matrix["os"], &1.status})
160 + end
161 +
162 + test "a true `if:` is queued", %{repo: repo} do
163 + run =
164 + run_workflow(
165 + repo,
166 + """
167 + name: ci
168 + on: push
169 + jobs:
170 + lint:
171 + if: ${{ github.ref == 'refs/heads/main' }}
172 + runs-on: self-hosted
173 + steps:
174 + - run: true
175 + """,
176 + "refs/heads/main"
177 + )
178 +
179 + assert job(run, "lint").status == "queued"
180 + end
181 +
182 + describe "what only the runner can decide is left to it" do
183 + for condition <- [
184 + "steps.x.outputs.y == 'z'",
185 + "always() && startsWith(github.ref, 'refs/tags/v')",
186 + "${{ success() && false }}",
187 + "env.DEPLOY == 'true'",
188 + "secrets.TOKEN != ''",
189 + "${{ github.ref }} == 'refs/tags/v1'"
190 + ] do
191 + test "if: #{condition}", %{repo: repo} do
192 + run =
193 + run_workflow(
194 + repo,
195 + """
196 + name: ci
197 + on: push
198 + jobs:
199 + build:
200 + if: #{inspect(unquote(condition))}
201 + runs-on: self-hosted
202 + steps:
203 + - run: true
204 + """,
205 + "refs/heads/main"
206 + )
207 +
208 + assert job(run, "build").status == "queued"
209 + end
210 + end
211 + end
212 +
213 + test "re-running a job stuck queued decides its `if:` then", %{repo: repo} do
214 + run = run_workflow(repo, @release, "refs/heads/master")
215 + {:ok, _} = Workflows.update_job_status(job(run, "build"), "success")
216 +
217 + # As though it was queued before the forge decided `if:` itself.
218 + for leg <- jobs(run, "release") do
219 + leg |> Ecto.Changeset.change(status: "queued", status_reason: nil) |> Repo.update!()
220 + end
221 +
222 + run |> WorkflowRun.status_changeset("running") |> Repo.update!()
223 + job(run, "create-release") |> Ecto.Changeset.change(status: "blocked") |> Repo.update!()
224 +
225 + {:ok, _} = Workflows.rerun_job(Repo.get!(WorkflowRun, run.id), "release")
226 +
227 + assert Enum.all?(jobs(run, "release"), &(&1.status == "skipped"))
228 + assert job(run, "create-release").status == "skipped"
229 + assert run_status(run) == "success"
230 + end
231 +end

Parents: 6c7ffb5