defmodule GitGud.Workflows.MatrixDispatchTest do
@moduledoc """
Matrix builds end to end through the orchestration: a workflow with a
matrix becomes several jobs, each routed to a runner that carries the
labels that leg asked for.
The motivating case is two architectures — an arm64 machine and an
amd64 one, both feeding one workflow — which is also the case that
exercises every moving part: expansion, `runs-on` substitution, and
label-set claiming.
"""
use GitGud.DataCase, async: false
alias GitGud.Workflows
alias GitGud.Workflows.Runners
alias GitGud.Workflows.WorkflowJob
alias GitGud.Workflows.WorkflowRun
alias GitGud.Workflows.Yaml
import GitGud.ForgeFixtures
setup do
{_user, repo} = repository_fixture()
{:ok, repo: repo}
end
# `dispatch/2` wants a real git tree to read workflows out of, and the
# tree isn't what's under test. `rerun/1` seeds jobs from a stored
# `workflow_doc` through exactly the same creation path, so a seed row
# plus a re-run gets the real thing without a repository on disk.
defp run_workflow(repo, yaml) do
{:ok, doc} = Yaml.parse(yaml)
seed =
%WorkflowRun{repository_id: repo.id}
|> WorkflowRun.create_changeset(%{
"number" => System.unique_integer([:positive]),
"workflow_path" => ".forgejo/workflows/ci.yml",
"workflow_name" => doc.name,
"trigger" => "push",
"head_ref" => "refs/heads/main",
"head_sha" => :crypto.strong_rand_bytes(20),
"workflow_doc" => doc.raw
})
|> Repo.insert!()
{:ok, run} = Workflows.rerun(seed)
run
end
defp jobs(run) do
from(j in WorkflowJob, where: j.workflow_run_id == ^run.id, order_by: j.id) |> Repo.all()
end
defp runner(repo, labels) do
{:ok, runner, _uuid, _token} =
Runners.create_offline(
%{name: "r-#{System.unique_integer([:positive])}", labels: %{"labels" => labels}},
repo
)
runner
end
describe "expansion" do
test "a two-architecture matrix becomes two jobs", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: [self-hosted, "${{ matrix.arch }}"]
steps:
- run: echo building for ${{ matrix.arch }}
""")
assert [amd, arm] = jobs(run)
# One YAML job id, two rows — `needs: build` still names the group.
assert amd.job_id == "build"
assert arm.job_id == "build"
assert amd.name == "build (amd64)"
assert arm.name == "build (arm64)"
assert amd.matrix == %{"arch" => "amd64"}
assert arm.matrix == %{"arch" => "arm64"}
end
test "`runs-on` is resolved per leg, because it decides the runner", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: [self-hosted, "${{ matrix.arch }}"]
steps:
- run: true
""")
assert [amd, arm] = jobs(run)
assert amd.runs_on == %{"labels" => ["self-hosted", "amd64"]}
assert arm.runs_on == %{"labels" => ["self-hosted", "arm64"]}
end
test "each leg's stored definition pins the matrix to one value", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: self-hosted
steps:
- run: echo ${{ matrix.arch }}
""")
assert [amd, _arm] = jobs(run)
# One value, so the runner's own engine expands it to exactly one
# job and `${{ matrix.arch }}` in the step still resolves. Left
# whole, the runner would fan out again inside this one job.
assert amd.definition["strategy"] == %{"matrix" => %{"arch" => ["amd64"]}}
end
test "a job with no matrix is still one job, and keeps its name", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
test:
name: unit tests
runs-on: ubuntu-latest
steps:
- run: true
""")
assert [job] = jobs(run)
assert job.name == "unit tests"
assert job.matrix == nil
assert job.runs_on == %{"labels" => ["ubuntu-latest"]}
refute Map.has_key?(job.definition, "strategy")
end
test "steps are created for every leg", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: self-hosted
steps:
- run: one
- run: two
""")
for job <- jobs(run) do
assert length(Repo.preload(job, :steps).steps) == 2
end
end
end
describe "routing to the right architecture" do
setup %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: [self-hosted, "${{ matrix.arch }}"]
steps:
- run: true
""")
{:ok, run: run}
end
test "an arm64 runner claims the arm64 leg and only that one", %{repo: repo, run: _run} do
arm = runner(repo, ["self-hosted", "arm64"])
assert %WorkflowJob{} = claimed = Workflows.claim_next_job(arm)
assert claimed.matrix == %{"arch" => "arm64"}
# Nothing else here is for arm64.
assert Workflows.claim_next_job(arm) == nil
end
test "two runners of different architectures take one leg each", %{repo: repo} do
amd = runner(repo, ["self-hosted", "amd64"])
arm = runner(repo, ["self-hosted", "arm64"])
assert amd_job = Workflows.claim_next_job(amd)
assert arm_job = Workflows.claim_next_job(arm)
assert amd_job.matrix == %{"arch" => "amd64"}
assert arm_job.matrix == %{"arch" => "arm64"}
refute amd_job.id == arm_job.id
# Both legs are spoken for.
assert Workflows.claim_next_job(amd) == nil
assert Workflows.claim_next_job(arm) == nil
end
test "a runner missing one of the required labels claims nothing", %{repo: repo} do
# Carries `self-hosted` but neither architecture.
idle = runner(repo, ["self-hosted", "riscv64"])
assert Workflows.claim_next_job(idle) == nil
end
test "a runner with extra labels still qualifies — containment, not equality", %{repo: repo} do
beefy = runner(repo, ["self-hosted", "arm64", "linux", "gpu"])
assert claimed = Workflows.claim_next_job(beefy)
assert claimed.matrix == %{"arch" => "arm64"}
end
end
describe "routing without a matrix" do
# Its own block: the matrix setup above queues work for both
# architectures, which is exactly what a "this runner should find
# nothing" assertion cannot have sitting around.
test "a scalar runs-on still routes, which is every pre-existing workflow", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
plain:
runs-on: ubuntu-latest
steps:
- run: true
""")
wrong = runner(repo, ["self-hosted", "arm64"])
right = runner(repo, ["ubuntu-latest"])
assert Workflows.claim_next_job(wrong) == nil
assert claimed = Workflows.claim_next_job(right)
assert claimed.workflow_run_id == run.id
end
test "`runs-on: self-hosted` matches any self-registered runner", %{repo: repo} do
run_workflow(repo, """
name: ci
on: push
jobs:
anywhere:
runs-on: self-hosted
steps:
- run: true
""")
# Doesn't carry the `self-hosted` label itself; it's implied.
any = runner(repo, ["arm64"])
assert claimed = Workflows.claim_next_job(any)
assert claimed.job_id == "anywhere"
end
end
describe "routing by architecture" do
defp aarch64_runner(repo, labels) do
{:ok, runner} = repo |> runner(labels) |> Runners.set_architecture("aarch64")
runner
end
defp self_hosted_job(repo) do
run_workflow(repo, """
name: ci
on: push
jobs:
anywhere:
runs-on: self-hosted
steps:
- run: true
""")
end
test "a runner is x86_64 unless set otherwise", %{repo: repo} do
assert runner(repo, ["ubuntu-latest"]).architecture == "x86_64"
end
test "an aarch64 runner doesn't take `runs-on: self-hosted` work", %{repo: repo} do
self_hosted_job(repo)
arm = aarch64_runner(repo, ["ubuntu-24.04-arm"])
assert Workflows.claim_next_job(arm) == nil
# It's still there for an x86_64 runner.
assert %WorkflowJob{job_id: "anywhere"} =
Workflows.claim_next_job(runner(repo, ["ubuntu-latest"]))
end
test "an aarch64 runner takes the work its labels name", %{repo: repo} do
run_workflow(repo, """
name: ci
on: push
jobs:
arm:
runs-on: ubuntu-24.04-arm
steps:
- run: true
""")
arm = aarch64_runner(repo, ["ubuntu-24.04-arm"])
assert %WorkflowJob{job_id: "arm"} = Workflows.claim_next_job(arm)
end
test "an aarch64 runner labelled self-hosted still leaves bare self-hosted work", %{
repo: repo
} do
self_hosted_job(repo)
arm = aarch64_runner(repo, ["self-hosted", "runner-aarch64"])
assert Workflows.claim_next_job(arm) == nil
run_workflow(repo, """
name: arm
on: push
jobs:
arm:
runs-on: [self-hosted, runner-aarch64]
steps:
- run: true
""")
assert %WorkflowJob{job_id: "arm"} = Workflows.claim_next_job(arm)
assert %WorkflowJob{job_id: "anywhere"} =
Workflows.claim_next_job(runner(repo, ["ubuntu-latest"]))
end
test "the architecture is a label", %{repo: repo} do
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [x86_64, aarch64]
runs-on: [self-hosted, "${{ matrix.arch }}"]
steps:
- run: true
""")
x86 = runner(repo, ["ubuntu-latest"])
arm = aarch64_runner(repo, ["self-hosted"])
assert Workflows.claim_next_job(x86).matrix == %{"arch" => "x86_64"}
assert Workflows.claim_next_job(arm).matrix == %{"arch" => "aarch64"}
assert Workflows.claim_next_job(x86) == nil
end
test "an unknown architecture is refused", %{repo: repo} do
assert {:error, cs} =
repo |> runner(["ubuntu-latest"]) |> Runners.set_architecture("riscv64")
assert %{architecture: [_]} = errors_on(cs)
end
end
describe "dependencies across a matrix" do
setup %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: [self-hosted, "${{ matrix.arch }}"]
steps:
- run: true
release:
needs: build
runs-on: self-hosted
steps:
- run: true
""")
{:ok, run: run}
end
test "a downstream job waits for every leg, not the first", %{run: run} do
[amd, arm, release] = jobs(run)
assert release.status == "blocked"
{:ok, _} = Workflows.update_job_status(amd, "success")
assert Repo.get!(WorkflowJob, release.id).status == "blocked"
{:ok, _} = Workflows.update_job_status(arm, "success")
assert Repo.get!(WorkflowJob, release.id).status == "queued"
end
test "one failing leg skips the downstream job", %{run: run} do
[amd, arm, release] = jobs(run)
{:ok, _} = Workflows.update_job_status(amd, "success")
{:ok, _} = Workflows.update_job_status(arm, "failure")
assert Repo.get!(WorkflowJob, release.id).status == "skipped"
end
test "the snapshot collapses the legs into one name", %{run: run} do
[amd, arm, release] = jobs(run)
{:ok, _} = Workflows.update_job_status(amd, "success")
{:ok, _} = Workflows.update_job_status(arm, "success")
# Downstream `${{ needs.build }}` is one name however many
# machines produced it.
assert %{"build" => %{"status" => "success"}} =
Repo.get!(WorkflowJob, release.id).needs_snapshot
end
end
describe "fail-fast" do
test "a failing leg cancels its queued siblings", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64, riscv64]
runs-on: [self-hosted, "${{ matrix.arch }}"]
steps:
- run: true
""")
[first | rest] = jobs(run)
{:ok, _} = Workflows.update_job_status(first, "failure")
for sibling <- rest do
assert Repo.get!(WorkflowJob, sibling.id).status == "cancelled"
end
end
test "`fail-fast: false` lets the others finish", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64, riscv64]
runs-on: [self-hosted, "${{ matrix.arch }}"]
steps:
- run: true
""")
[first | rest] = jobs(run)
{:ok, _} = Workflows.update_job_status(first, "failure")
for sibling <- rest do
assert Repo.get!(WorkflowJob, sibling.id).status == "queued"
end
end
test "a leg already running is cancelled too", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
arch: [amd64, arm64]
runs-on: self-hosted
steps:
- run: true
""")
[first, second] = jobs(run)
{:ok, _} = Workflows.update_job_status(second, "running")
{:ok, _} = Workflows.update_job_status(first, "failure")
# As on GitHub. Its runner hears it on the next UpdateTask, and
# still uploads the log it has.
assert Repo.get!(WorkflowJob, second.id).status == "cancelled"
end
test "a job with no matrix never fail-fasts its unrelated siblings", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
a:
runs-on: self-hosted
steps:
- run: true
b:
runs-on: self-hosted
steps:
- run: true
""")
[a, b] = jobs(run)
{:ok, _} = Workflows.update_job_status(a, "failure")
assert Repo.get!(WorkflowJob, b.id).status == "queued"
end
end
describe "re-running" do
test "re-running a matrix job re-runs every leg, not the first one found", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
runs-on: self-hosted
steps:
- run: true
""")
[amd, arm] = jobs(run)
{:ok, _} = Workflows.update_job_status(amd, "failure")
{:ok, _} = Workflows.update_job_status(arm, "success")
assert {:ok, _} = Workflows.rerun_job(run, "build")
assert Repo.get!(WorkflowJob, amd.id).status == "queued"
assert Repo.get!(WorkflowJob, arm.id).status == "queued"
end
test "an unknown job id is still not found", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
runs-on: self-hosted
steps:
- run: true
""")
assert {:error, :not_found} = Workflows.rerun_job(run, "nope")
end
end
describe "the run as a whole" do
test "completes only once every leg is terminal", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
runs-on: self-hosted
steps:
- run: true
""")
[amd, arm] = jobs(run)
{:ok, _} = Workflows.update_job_status(amd, "success")
assert Repo.get!(WorkflowRun, run.id).status in ~w(queued running)
{:ok, _} = Workflows.update_job_status(arm, "success")
assert Repo.get!(WorkflowRun, run.id).status == "success"
end
end
describe "a matrix computed by an earlier job" do
# The failures log a warning, as they should.
@describetag :capture_log
setup %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
setup:
runs-on: self-hosted
steps:
- run: true
build:
needs: setup
strategy:
matrix: ${{ fromJSON(needs.setup.outputs.m) }}
runs-on: self-hosted
steps:
- run: echo ${{ matrix.os }}
deploy:
needs: build
runs-on: self-hosted
steps:
- run: true
""")
{:ok, run: run}
end
defp finish_setup(run, outputs) do
setup = Enum.find(jobs(run), &(&1.job_id == "setup"))
{:ok, setup} = Workflows.set_outputs(setup, outputs)
{:ok, _} = Workflows.update_job_status(setup, "success")
end
test "waits as one placeholder, then becomes a leg per combination", %{run: run} do
assert [placeholder] = Enum.filter(jobs(run), &(&1.job_id == "build"))
assert placeholder.name == "build"
assert placeholder.status == "blocked"
assert placeholder.matrix == nil
finish_setup(run, %{"m" => ~s({"os":["a","b"]})})
refute Repo.get(WorkflowJob, placeholder.id)
legs = Enum.filter(jobs(run), &(&1.job_id == "build"))
assert Enum.map(legs, & &1.matrix) == [%{"os" => "a"}, %{"os" => "b"}]
assert Enum.map(legs, & &1.name) == ["build (a)", "build (b)"]
assert Enum.all?(legs, &(&1.status == "queued"))
assert Enum.all?(legs, &(&1.needs_snapshot["setup"]["status"] == "success"))
assert Enum.all?(
legs,
&(&1.definition["strategy"]["matrix"] == %{"os" => [&1.matrix["os"]]})
)
assert Enum.all?(legs, &(length(Repo.preload(&1, :steps).steps) == 1))
# The placeholder's pending status went with it; each leg has its own.
contexts =
run.repository_id
|> then(
&Workflows.list_commit_statuses(%GitGud.Repositories.Repository{id: &1}, run.head_sha)
)
|> Enum.map(& &1.context)
refute "ci / build (push)" in contexts
assert "ci / build (a) (push)" in contexts
end
test "a job needing it waits for every leg", %{run: run} do
finish_setup(run, %{"m" => ~s({"os":["a","b"]})})
[a, b] = Enum.filter(jobs(run), &(&1.job_id == "build"))
deploy = Enum.find(jobs(run), &(&1.job_id == "deploy"))
{:ok, _} = Workflows.update_job_status(a, "success")
assert Repo.get!(WorkflowJob, deploy.id).status == "blocked"
{:ok, _} = Workflows.update_job_status(b, "success")
assert Repo.get!(WorkflowJob, deploy.id).status == "queued"
end
test "an output that isn't a matrix fails the job, saying why", %{run: run} do
finish_setup(run, %{"m" => "nope"})
[build] = Enum.filter(jobs(run), &(&1.job_id == "build"))
assert build.status == "failure"
assert build.status_reason =~ "could not be evaluated"
[step | _] =
build |> Repo.preload(:steps) |> Map.fetch!(:steps) |> Enum.sort_by(& &1.number)
assert [%{body: "Error: " <> _}] = Workflows.list_step_logs(step)
assert Enum.find(jobs(run), &(&1.job_id == "deploy")).status == "skipped"
assert Repo.get!(WorkflowRun, run.id).status == "failure"
end
test "re-running the failed job expands it again", %{run: run} do
finish_setup(run, %{"m" => "nope"})
setup = Enum.find(jobs(run), &(&1.job_id == "setup"))
{:ok, _} = Workflows.set_outputs(setup, %{"m" => ~s({"os":["a"]})})
{:ok, _} = Workflows.rerun_job(Repo.get!(WorkflowRun, run.id), "build")
assert [%{matrix: %{"os" => "a"}, status: "queued"}] =
Enum.filter(jobs(run), &(&1.job_id == "build"))
assert Repo.get!(WorkflowRun, run.id).status == "running"
end
end
describe "a matrix computed without needs" do
test "is expanded as the run is created", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
os: ${{ fromJSON('["a", "b"]') }}
ref: ["${{ github.ref_name }}"]
runs-on: self-hosted
steps:
- run: true
""")
assert Enum.map(jobs(run), & &1.matrix) == [
%{"os" => "a", "ref" => "main"},
%{"os" => "b", "ref" => "main"}
]
end
@tag :capture_log
test "one that excludes everything fails the run", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
os: [a]
exclude:
- os: a
runs-on: self-hosted
steps:
- run: true
""")
assert [%{status: "failure", status_reason: reason}] = jobs(run)
assert reason =~ "no combinations"
assert Repo.get!(WorkflowRun, run.id).status == "failure"
end
@tag :capture_log
test "re-running one that excludes everything fails it again, unclaimed", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
os: [a]
exclude:
- os: a
runs-on: self-hosted
steps:
- run: true
""")
{:ok, _} = Workflows.rerun_job(Repo.get!(WorkflowRun, run.id), "build")
assert [%{status: "failure", attempt: 2, status_reason: reason}] = jobs(run)
assert reason =~ "no combinations"
assert Workflows.claim_next_job(runner(repo, ["self-hosted"])) == nil
assert Repo.get!(WorkflowRun, run.id).status == "failure"
end
end
describe "max-parallel" do
test "caps the legs of a job running at once", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
deploy:
strategy:
max-parallel: 1
matrix:
env: [a, b, c]
runs-on: self-hosted
steps:
- run: true
""")
[r1, r2, r3] = for _ <- 1..3, do: runner(repo, ["self-hosted"])
assert %WorkflowJob{} = first = Workflows.claim_next_job(r1)
assert Workflows.claim_next_job(r2) == nil
assert Workflows.claim_jobs_for(r3, 3) == []
[_, second, _] = jobs(run)
assert Workflows.claim_job_by_handle(r2, Integer.to_string(second.id)) == nil
{:ok, _} = Workflows.update_job_status(first, "success")
assert %WorkflowJob{} = Workflows.claim_next_job(r2)
assert Workflows.claim_next_job(r3) == nil
end
test "another job's legs are still claimable meanwhile", %{repo: repo} do
run_workflow(repo, """
name: ci
on: push
jobs:
deploy:
strategy:
max-parallel: 1
matrix:
env: [a, b]
runs-on: self-hosted
steps:
- run: true
test:
runs-on: self-hosted
steps:
- run: true
""")
r = runner(repo, ["self-hosted"])
claimed = Workflows.claim_jobs_for(r, 3)
assert claimed |> Enum.map(& &1.job_id) |> Enum.sort() == ["deploy", "test"]
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
24.2 KiB · text
History
b868907