neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Actions: decide a job's `if:` on the server when it can
1f1d2a3 · Gabriel Morell · 2026-10-31 05:14
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