defmodule GitGud.Workflows.Matrix do
@moduledoc """
Expands a job's `strategy.matrix` into the combinations it stands for.
strategy:
matrix:
arch: [amd64, arm64]
otp: ["27", "28"]
…is four jobs. Each becomes its own `WorkflowJob` row, with its own
status, logs and runner — which is the point: two of those want an
amd64 machine and two want arm64, and only separate rows can be
claimed by separate runners.
## `include` and `exclude`
Both are ordinary GitHub Actions semantics and both are fiddly, so
they are spelled out here rather than left to the reader:
* `exclude` drops every combination that matches *all* of the
key/values it names. It is applied before `include`, so an
`include` can add a combination back.
* `include` is not "append these combinations". Each entry is
merged into every existing combination whose overlapping keys
already agree with it — so `{otp: "28", coverage: true}` adds
`coverage` to the two OTP-28 jobs rather than creating a fifth.
An entry that matches nothing is appended as a combination of its
own, which is how you add a single odd platform to a matrix.
An entry that *would* overwrite a value the base matrix set is where
GitHub's own rules get genuinely ambiguous; here, overlapping keys
must agree for a merge to happen, so such an entry appends instead.
## Ordering
Combinations come back in the order GitHub produces them: the *last*
matrix key varies fastest. That is only cosmetic, but it decides the
order jobs appear in the UI, and matching the tool people already
know beats an arbitrary choice.
"""
@reserved ~w(include exclude)
@typedoc "One combination: matrix key to the value this leg was expanded for."
@type combination :: %{String.t() => term()}
@doc """
Every combination `strategy` expands to.
Returns `[%{}]` — a single empty combination — when there is no
matrix, so callers can treat "no matrix" as "one leg with nothing in
it" rather than branching.
"""
@spec expand(map() | nil) :: [combination()]
def expand(strategy)
def expand(%{"matrix" => %{} = matrix}) do
axes =
matrix
|> Map.drop(@reserved)
|> Enum.map(fn {key, values} -> {key, List.wrap(values)} end)
# Map iteration order is undefined; sort so a given workflow always
# expands the same way rather than shuffling between deploys.
|> Enum.sort_by(&elem(&1, 0))
axes
|> product()
|> exclude(List.wrap(matrix["exclude"]))
|> include(List.wrap(matrix["include"]))
|> case do
[] -> [%{}]
combinations -> combinations
end
end
def expand(_strategy), do: [%{}]
@doc """
Whether `strategy` asks for a matrix at all.
A `strategy:` with only `fail-fast:` on it is not a matrix, and its
job should not be renamed as though it were one leg of something.
"""
@spec matrix?(map() | nil) :: boolean()
def matrix?(%{"matrix" => %{} = matrix}), do: matrix != %{}
def matrix?(_), do: false
@doc """
Whether a failing leg should cancel its siblings. GitHub's default is
`true`, and so is ours.
"""
@spec fail_fast?(map() | nil) :: boolean()
def fail_fast?(%{"fail-fast" => false}), do: false
def fail_fast?(%{"fail-fast" => "false"}), do: false
def fail_fast?(_), do: true
@doc """
The display suffix GitHub puts on a matrix leg — `build (arm64, 28)`.
Values in the order their keys sort, which is the order `expand/1`
varied them, so reading two names side by side tells you which axis
differs.
"""
@spec suffix(combination()) :: String.t() | nil
def suffix(combination) when map_size(combination) == 0, do: nil
def suffix(combination) do
combination
|> Enum.sort_by(&elem(&1, 0))
|> Enum.map_join(", ", fn {_key, value} -> to_string(value) end)
end
@doc """
Substitute `${{ matrix.key }}` in a `runs-on` value.
Only `runs-on` needs this. Everything else the runner evaluates for
itself — see `GitGud.Workflows.RunnerPayload`, which hands `act` a
one-value matrix so the whole `matrix` context resolves natively.
But routing is *our* decision and happens before any runner sees the
job, so this one expression has to be resolved server-side.
An unknown key resolves to the empty string, as it does in Actions.
"""
@spec substitute(term(), combination()) :: term()
def substitute(value, combination) when is_list(value),
do: Enum.map(value, &substitute(&1, combination))
def substitute(value, combination) when is_binary(value) do
Regex.replace(~r/\$\{\{\s*matrix\.([A-Za-z_][A-Za-z0-9_-]*)\s*\}\}/, value, fn _, key ->
combination |> Map.get(key, "") |> to_string()
end)
end
def substitute(value, _combination), do: value
@doc """
Normalise a `runs-on` into the list of labels a runner must carry.
`runs-on: ubuntu-latest` and `runs-on: [ubuntu-latest]` mean the same
thing and are stored the same way, so claiming has one shape to match
rather than two.
"""
@spec labels(term()) :: [String.t()]
def labels(runs_on) do
runs_on
|> List.wrap()
|> Enum.map(&to_string/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
end
@doc """
Rewrite a job definition so it describes exactly this one leg.
The matrix is replaced by the single combination rather than
removed: `act` then expands it to exactly one job and populates the
`matrix` context from it, so `${{ matrix.arch }}` inside a step keeps
working without us implementing an expression evaluator. Dropping
`strategy` entirely would leave those expressions resolving to
nothing; leaving it whole would have the runner fan out *again*,
inside the single job we just handed it.
"""
@spec pin_definition(map(), combination()) :: map()
def pin_definition(definition, combination) when map_size(combination) == 0,
do: Map.drop(definition, ["strategy"])
def pin_definition(definition, combination) do
pinned = Map.new(combination, fn {key, value} -> {key, [value]} end)
strategy = Map.get(definition, "strategy") || %{}
# `fail-fast` rides along even though it means nothing to a
# one-value matrix: the forge reads it back off the stored
# definition to decide whether a failing leg cancels its siblings,
# and the pinned strategy is the only copy that survives.
kept =
case Map.fetch(strategy, "fail-fast") do
{:ok, value} -> %{"matrix" => pinned, "fail-fast" => value}
:error -> %{"matrix" => pinned}
end
Map.put(definition, "strategy", kept)
end
# ── expansion internals ─────────────────────────────────────────────
# Cartesian product with the last axis varying fastest, matching the
# order Actions lists a matrix's jobs in.
defp product([]), do: []
defp product(axes) do
Enum.reduce(axes, [%{}], fn {key, values}, acc ->
for combination <- acc, value <- values, do: Map.put(combination, key, value)
end)
end
defp exclude(combinations, []), do: combinations
defp exclude(combinations, exclusions) do
Enum.reject(combinations, fn combination ->
Enum.any?(exclusions, &subset_of?(&1, combination))
end)
end
defp include(combinations, []), do: combinations
defp include(combinations, inclusions) do
Enum.reduce(inclusions, combinations, fn entry, acc ->
entry = Map.new(entry, fn {k, v} -> {to_string(k), v} end)
merged = Enum.map(acc, &merge_if_compatible(&1, entry))
# Nothing took it — it describes a combination of its own.
if merged == acc, do: acc ++ [entry], else: merged
end)
end
# Compatible means every key they share already agrees. An entry that
# would change an existing value describes a different combination,
# not an addition to this one.
defp merge_if_compatible(combination, entry) do
overlapping = Map.take(entry, Map.keys(combination))
if subset_of?(overlapping, combination) and entry != overlapping,
do: Map.merge(combination, entry),
else: combination
end
defp subset_of?(subset, combination) do
Enum.all?(subset, fn {key, value} ->
Map.has_key?(combination, key) and to_string(Map.get(combination, key)) == to_string(value)
end)
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
8.2 KiB · text
History
cac71de