defmodule GitGud.Workflows.ConcurrencyTest do
@moduledoc """
`concurrency:` — at most one run (or job) of a group in progress and one
waiting, as on GitHub.
Workflow-level groups are driven through real pushes (`dispatch/2` reads
the workflow out of a commit on disk); job-level ones through `rerun/1`
of a stored document, which creates jobs by the same path.
"""
use GitGud.DataCase, async: false
import ExUnit.CaptureLog
import GitGud.ForgeFixtures
alias GitGud.Repositories.Repository
alias GitGud.Workflows
alias GitGud.Workflows.WorkflowJob
alias GitGud.Workflows.WorkflowRun
alias GitGud.Workflows.Yaml
setup do
{_user, repo} = repository_fixture()
{:ok, repo: repo}
end
defp workflow(concurrency) do
"""
name: deploy
on: push
concurrency: #{concurrency}
jobs:
deploy:
runs-on: self-hosted
steps:
- run: ./deploy
"""
end
# One commit holding the workflow; every push of it to `ref` makes a run.
defp commit!(%Repository{disk_path: path}, yaml) do
dir = Path.join(System.tmp_dir!(), "concurrency-#{System.unique_integer([:positive])}")
File.mkdir_p!(dir)
file = Path.join(dir, "ci.yml")
File.write!(file, yaml)
env = [{"GIT_INDEX_FILE", Path.join(dir, "index")}]
try do
blob = git!(path, ["hash-object", "-w", file])
git!(
path,
["update-index", "--add", "--cacheinfo", "100644,#{blob},.forgejo/workflows/ci.yml"],
env
)
tree = git!(path, ["write-tree"], env)
git!(path, ["commit-tree", tree, "-m", "ci"])
after
File.rm_rf!(dir)
end
end
defp git!(path, args, env \\ []) do
{out, 0} = System.cmd("git", args, cd: path, stderr_to_stdout: true, env: env)
String.trim(out)
end
defp push!(repo, sha, ref \\ "refs/heads/main") do
git!(repo.disk_path, ["update-ref", ref, sha])
{:ok, head} = GitGud.Git.from_hex(sha)
{:ok, [run]} = Workflows.dispatch(repo, %{trigger: "push", head_sha: head, ref: ref})
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 job(run), do: run |> jobs() |> hd()
defp run(run), do: Repo.get!(WorkflowRun, run.id)
describe "workflow level" do
test "one in progress, one waiting; a newer run replaces the waiting one", %{repo: repo} do
sha = commit!(repo, workflow("{group: deploy, cancel-in-progress: false}"))
first = push!(repo, sha)
assert run(first).concurrency_group == "deploy"
assert job(first).status == "queued"
second = push!(repo, sha)
assert %WorkflowJob{status: "waiting", status_reason: "Waiting for deploy"} = job(second)
assert run(second).status == "queued"
third = push!(repo, sha)
assert run(second).status == "cancelled"
assert %WorkflowJob{status: "cancelled", status_reason: reason} = job(second)
assert reason =~ "deploy"
assert job(third).status == "waiting"
assert job(first).status == "queued"
# The first finishing lets the third go.
{:ok, _} = Workflows.update_job_status(job(first), "running")
{:ok, _} = Workflows.update_job_status(job(first), "success")
assert run(first).status == "success"
assert %WorkflowJob{status: "queued", status_reason: nil} = job(third)
end
test "a cancelled run lets the next one go too", %{repo: repo} do
sha = commit!(repo, workflow("deploy"))
first = push!(repo, sha)
second = push!(repo, sha)
assert job(second).status == "waiting"
{:ok, _} = Workflows.cancel(run(first))
assert job(second).status == "queued"
end
test "cancel-in-progress cancels the running run, and the new one starts", %{repo: repo} do
sha = commit!(repo, workflow("{group: deploy, cancel-in-progress: true}"))
first = push!(repo, sha)
{:ok, _} = Workflows.update_job_status(job(first), "running")
second = push!(repo, sha)
assert run(first).status == "cancelled"
assert job(first).status == "cancelled"
assert job(second).status == "queued"
end
test "a group per ref: pushes to two branches don't interact", %{repo: repo} do
sha = commit!(repo, workflow("${{ github.workflow }}-${{ github.ref }}"))
main = push!(repo, sha, "refs/heads/main")
feature = push!(repo, sha, "refs/heads/feature")
assert run(main).concurrency_group == "deploy-refs/heads/main"
assert run(feature).concurrency_group == "deploy-refs/heads/feature"
assert job(main).status == "queued"
assert job(feature).status == "queued"
again = push!(repo, sha, "refs/heads/main")
assert job(again).status == "waiting"
assert job(feature).status == "queued"
end
test "a group we can't evaluate runs without concurrency control", %{repo: repo} do
sha = commit!(repo, workflow("${{ toJSON(github) }}"))
log =
capture_log(fn ->
first = push!(repo, sha)
second = push!(repo, sha)
send(self(), {:runs, first, second})
end)
assert_received {:runs, first, second}
assert log =~ "concurrency ignored"
assert run(first).concurrency_group == nil
for r <- [first, second] do
assert %WorkflowJob{status: "queued", status_reason: "Concurrency ignored: " <> _} =
job(r)
end
end
end
# `rerun/1` of a stored document creates jobs exactly as a dispatch does.
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
describe "job level" do
test "legs in the same group serialize, legs in different groups don't", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
deploy:
strategy:
matrix:
target: [a, b]
n: [1, 2]
concurrency: deploy-${{ matrix.target }}
runs-on: self-hosted
steps:
- run: ./deploy
""")
legs = Map.new(jobs(run), &{{&1.matrix["target"], &1.matrix["n"]}, &1})
assert legs[{"a", 1}].status == "queued"
assert %WorkflowJob{status: "waiting", status_reason: "Waiting for deploy-a"} =
legs[{"a", 2}]
assert legs[{"b", 1}].status == "queued"
assert legs[{"b", 2}].status == "waiting"
{:ok, _} = Workflows.update_job_status(legs[{"a", 1}], "success")
assert Repo.get!(WorkflowJob, legs[{"a", 2}].id).status == "queued"
assert Repo.get!(WorkflowJob, legs[{"b", 2}].id).status == "waiting"
assert Repo.get!(WorkflowRun, run.id).status == "queued"
end
test "decided when the job is queued, so it can read needs", %{repo: repo} do
run =
run_workflow(repo, """
name: ci
on: push
jobs:
build:
runs-on: self-hosted
steps:
- run: true
deploy:
needs: build
concurrency: deploy-${{ needs.build.result }}
runs-on: self-hosted
steps:
- run: ./deploy
""")
[build, deploy] = jobs(run)
assert deploy.status == "blocked"
assert deploy.concurrency_group == nil
{:ok, _} = Workflows.update_job_status(build, "success")
assert %WorkflowJob{status: "queued", concurrency_group: "deploy-success"} =
Repo.get!(WorkflowJob, deploy.id)
end
test "cancel-in-progress across runs: the older job is superseded", %{repo: repo} do
yaml = """
name: ci
on: push
jobs:
deploy:
concurrency: {group: deploy, cancel-in-progress: true}
runs-on: self-hosted
steps:
- run: ./deploy
"""
first = run_workflow(repo, yaml)
{:ok, _} = Workflows.update_job_status(job(first), "running")
second = run_workflow(repo, yaml)
assert job(second).status == "queued"
assert %WorkflowJob{
status: "cancelled",
status_reason: "Cancelled by a newer job in deploy"
} =
job(first)
# Superseded, not failed.
assert run(first).status == "cancelled"
end
test "cancelling the run of the job in progress lets the waiting one go", %{repo: repo} do
yaml = """
name: ci
on: push
jobs:
deploy:
concurrency: deploy
runs-on: self-hosted
steps:
- run: ./deploy
"""
first = run_workflow(repo, yaml)
second = run_workflow(repo, yaml)
assert job(second).status == "waiting"
{:ok, _} = Workflows.cancel(run(first))
assert %WorkflowJob{status: "queued", status_reason: nil} = job(second)
end
test "re-running a job puts it back through its group", %{repo: repo} do
yaml = """
name: ci
on: push
jobs:
deploy:
concurrency: deploy
runs-on: self-hosted
steps:
- run: ./deploy
"""
first = run_workflow(repo, yaml)
{:ok, _} = Workflows.update_job_status(job(first), "failure")
second = run_workflow(repo, yaml)
assert job(second).status == "queued"
{:ok, _} = Workflows.rerun_job(run(first), "deploy")
assert %WorkflowJob{status: "waiting", attempt: 2} = job(first)
end
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
9.7 KiB · text
History
b868907