defmodule GitGud.Workflows.JobGraphTest do
@moduledoc """
What a job's `needs:` make of it once they finish, and what the run
concludes — GitHub's rules: a failed, cancelled or skipped need skips
its dependents unless their `if:` calls a status function, and
`continue-on-error` hides a failure from both. Such a job, or one that
reports `gitgud-conclusion: warning`, concludes `warning`.
"""
use GitGud.DataCase, async: false
alias GitGud.Workflows
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
# As in MatrixDispatchTest: a seed row plus a re-run creates the jobs
# through the real path 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 job(run, job_id) do
Repo.one!(from j in WorkflowJob, where: j.workflow_run_id == ^run.id and j.job_id == ^job_id)
end
defp status(run, job_id), do: job(run, job_id).status
defp run_status(run), do: Repo.get!(WorkflowRun, run.id).status
describe "after a failure" do
setup %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
runs-on: self-hosted
steps:
- run: false
deploy:
needs: build
runs-on: self-hosted
steps:
- run: true
notify:
needs: build
if: always()
runs-on: self-hosted
steps:
- run: true
report:
needs: build
if: ${{ failure() }}
runs-on: self-hosted
steps:
- run: true
tidy:
needs: build
if: "!cancelled()"
runs-on: self-hosted
steps:
- run: true
""")
{:ok, run: run}
end
test "a plain dependent is skipped, not cancelled", %{run: run} do
{:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
assert status(run, "deploy") == "skipped"
end
test "dependents whose `if:` calls a status function are queued with the real result",
%{run: run} do
{:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
for id <- ~w(notify report tidy) do
dependent = job(run, id)
assert dependent.status == "queued"
assert %{"build" => %{"status" => "failure"}} = dependent.needs_snapshot
end
end
test "the run fails once the queued dependents finish", %{run: run} do
{:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
assert run_status(run) in ~w(queued running)
for id <- ~w(notify report tidy),
do: {:ok, _} = Workflows.update_job_status(job(run, id), "success")
assert run_status(run) == "failure"
end
end
test "a skipped need skips a dependent without a status function", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
runs-on: self-hosted
steps:
- run: true
deploy:
needs: build
runs-on: self-hosted
steps:
- run: true
""")
{:ok, _} = Workflows.update_job_status(job(run, "build"), "skipped")
assert status(run, "deploy") == "skipped"
# Skipped jobs don't fail a run.
assert run_status(run) == "success"
end
test "a failure skips a whole chain", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
a:
runs-on: self-hosted
steps:
- run: false
b:
needs: a
runs-on: self-hosted
steps:
- run: true
c:
needs: [a, b]
runs-on: self-hosted
steps:
- run: true
""")
{:ok, _} = Workflows.update_job_status(job(run, "a"), "failure")
assert status(run, "b") == "skipped"
assert status(run, "c") == "skipped"
assert run_status(run) == "failure"
end
test "a dependent waits for every need before it's decided", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
a:
runs-on: self-hosted
steps:
- run: false
b:
runs-on: self-hosted
steps:
- run: true
c:
needs: [a, b]
if: always()
runs-on: self-hosted
steps:
- run: true
""")
{:ok, _} = Workflows.update_job_status(job(run, "a"), "failure")
assert status(run, "c") == "blocked"
{:ok, _} = Workflows.update_job_status(job(run, "b"), "success")
assert status(run, "c") == "queued"
end
describe "continue-on-error" do
test "a failure doesn't hold back dependents or fail the run", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
continue-on-error: true
runs-on: self-hosted
steps:
- run: false
deploy:
needs: build
runs-on: self-hosted
steps:
- run: true
""")
{:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
deploy = job(run, "deploy")
assert deploy.status == "queued"
assert %{"build" => %{"status" => "success"}} = deploy.needs_snapshot
{:ok, _} = Workflows.update_job_status(deploy, "success")
assert status(run, "build") == "warning"
assert run_status(run) == "success"
end
test "is read through a matrix value, and spares the leg's siblings fail-fast", %{
repo: repo
} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
strategy:
matrix:
experimental: [true, false]
continue-on-error: ${{ matrix.experimental }}
runs-on: self-hosted
steps:
- run: true
""")
[experimental, stable] =
from(j in WorkflowJob, where: j.workflow_run_id == ^run.id, order_by: j.id)
|> Repo.all()
|> Enum.sort_by(&(&1.matrix["experimental"] != true))
assert Workflows.effective_result(%{experimental | status: "failure"}) == "success"
assert Workflows.effective_result(%{stable | status: "failure"}) == "failure"
{:ok, _} = Workflows.update_job_status(experimental, "failure")
assert Repo.get!(WorkflowJob, experimental.id).status == "warning"
assert Repo.get!(WorkflowJob, stable.id).status == "queued"
# The stable leg has no continue-on-error: its failure is one.
{:ok, _} = Workflows.update_job_status(stable, "failure")
assert Repo.get!(WorkflowJob, stable.id).status == "failure"
assert run_status(run) == "failure"
end
end
describe "gitgud-conclusion" do
@two_jobs """
name: ci
on: push
jobs:
audit:
runs-on: self-hosted
outputs:
gitgud-conclusion: ${{ steps.scan.outputs.conclusion }}
steps:
- id: scan
run: true
deploy:
needs: audit
runs-on: self-hosted
steps:
- run: true
"""
defp finish(job, status, outputs) do
{:ok, job} = Workflows.set_outputs(job, outputs)
{:ok, job} = Workflows.update_job_status(job, status)
job
end
test "a success reporting warning concludes warning; dependents see success", %{
repo: repo
} do
run = run_workflow(repo, @two_jobs)
audit = finish(job(run, "audit"), "success", %{"gitgud-conclusion" => " Warning\n"})
assert audit.status == "warning"
assert audit.conclusion == "warning"
assert audit.status_reason =~ "gitgud-conclusion"
assert Workflows.effective_result(audit) == "success"
deploy = job(run, "deploy")
assert deploy.status == "queued"
assert %{"audit" => %{"status" => "success"}} = deploy.needs_snapshot
{:ok, _} = Workflows.update_job_status(deploy, "success")
assert run_status(run) == "success"
end
test "any other value is ignored", %{repo: repo} do
for value <- ["success", "warn", "warnings", "failure", ""] do
run = run_workflow(repo, @two_jobs)
assert finish(job(run, "audit"), "success", %{"gitgud-conclusion" => value}).status ==
"success"
end
end
test "can't excuse a failure", %{repo: repo} do
run = run_workflow(repo, @two_jobs)
audit = finish(job(run, "audit"), "failure", %{"gitgud-conclusion" => "warning"})
assert audit.status == "failure"
assert status(run, "deploy") == "skipped"
assert run_status(run) == "failure"
end
test "a re-run starts without the last attempt's outputs", %{repo: repo} do
run = run_workflow(repo, @two_jobs)
finish(job(run, "audit"), "success", %{"gitgud-conclusion" => "warning"})
{:ok, _} = Workflows.rerun_job(Repo.get!(WorkflowRun, run.id), "audit")
audit = job(run, "audit")
assert audit.outputs == %{}
{:ok, audit} = Workflows.update_job_status(audit, "success")
assert audit.status == "success"
end
end
test "a matrix need with one failed leg reports failure", %{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
report:
needs: build
if: always()
runs-on: self-hosted
steps:
- run: true
""")
[amd, arm] =
Repo.all(
from j in WorkflowJob,
where: j.workflow_run_id == ^run.id and j.job_id == "build",
order_by: j.id
)
{:ok, _} = Workflows.update_job_status(amd, "success")
{:ok, _} = Workflows.update_job_status(arm, "failure")
assert %{"build" => %{"status" => "failure"}} = job(run, "report").needs_snapshot
end
test "re-running a skipped dependent whose need still failed skips it again", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
runs-on: self-hosted
steps:
- run: false
deploy:
needs: build
runs-on: self-hosted
steps:
- run: true
""")
{:ok, _} = Workflows.update_job_status(job(run, "build"), "failure")
assert {:ok, _} = Workflows.rerun_job(Repo.get!(WorkflowRun, run.id), "deploy")
assert status(run, "deploy") == "skipped"
assert run_status(run) == "failure"
end
test "a cancelled run cancels blocked jobs, `always()` or not", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
runs-on: self-hosted
steps:
- run: true
notify:
needs: build
if: always()
runs-on: self-hosted
steps:
- run: true
""")
{:ok, _} = Workflows.cancel(Repo.get!(WorkflowRun, run.id))
assert status(run, "build") == "cancelled"
assert status(run, "notify") == "cancelled"
assert run_status(run) == "cancelled"
end
describe "status_function_if?/1" do
test "spots the status functions, negated or wrapped" do
for condition <- [
"always()",
"${{ failure() }}",
"!cancelled()",
"success() || failure ( )",
"always() && needs.build.outputs.x == '1'"
] do
assert Workflows.status_function_if?(%{"if" => condition}), condition
end
end
test "ignores everything else" do
for definition <- [
%{},
%{"if" => true},
%{"if" => "success()"},
%{"if" => "github.ref == 'refs/heads/main'"},
%{"if" => "needs.always_on.result == 'success'"}
] do
refute Workflows.status_function_if?(definition), inspect(definition)
end
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
12.6 KiB · text
History
b868907