6.6 KiB · text History b868907
defmodule GitGud.Workflows.JobIfTest do
  @moduledoc """
  A job's `if:`, decided by the forge as the job would be queued, when
  it can be: a false one skips the job (and so its dependents, and maybe
  finishes the run) instead of leaving it queued for a runner that may
  never come — a `macos-latest` leg of a release matrix on a branch push,
  say. Anything the forge can't evaluate is queued for the runner, as
  before.
  """
  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 JobGraphTest: a seed row plus a re-run creates the jobs through
  # the real path without a repository on disk.
  defp run_workflow(repo, yaml, ref) 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" => ref,
        "head_sha" => :crypto.strong_rand_bytes(20),
        "workflow_doc" => doc.raw
      })
      |> Repo.insert!()

    {:ok, run} = Workflows.rerun(seed)
    run
  end

  defp jobs(run, job_id) do
    Repo.all(
      from j in WorkflowJob,
        where: j.workflow_run_id == ^run.id and j.job_id == ^job_id,
        order_by: j.id
    )
  end

  defp job(run, job_id), do: run |> jobs(job_id) |> hd()
  defp run_status(run), do: Repo.get!(WorkflowRun, run.id).status

  # The shape of neiam/git-friends' rust-ci.yml: a release matrix across
  # runners nobody here carries, only for tags.
  @release """
  name: ci
  on: push
  jobs:
    build:
      runs-on: self-hosted
      steps:
        - run: true
    release:
      needs: build
      if: startsWith(github.ref, 'refs/tags/v')
      strategy:
        matrix:
          include:
            - runner: ubuntu-latest
            - runner: ubuntu-24.04-arm
            - runner: macos-latest
            - runner: windows-latest
      runs-on: ${{ matrix.runner }}
      steps:
        - run: true
    create-release:
      needs: release
      if: startsWith(github.ref, 'refs/tags/v')
      runs-on: self-hosted
      steps:
        - run: true
  """

  describe "a release matrix only for tags" do
    test "on a branch push, every leg and its dependent is skipped, and the run succeeds",
         %{repo: repo} do
      run = run_workflow(repo, @release, "refs/heads/master")
      assert Enum.all?(jobs(run, "release"), &(&1.status == "blocked"))

      {:ok, _} = Workflows.update_job_status(job(run, "build"), "success")

      legs = jobs(run, "release")
      assert length(legs) == 4
      assert Enum.all?(legs, &(&1.status == "skipped"))
      assert Enum.all?(legs, &(&1.status_reason == "Skipped: `if:` is false"))
      assert job(run, "create-release").status == "skipped"
      assert run_status(run) == "success"
    end

    test "on a tag push, the legs are queued", %{repo: repo} do
      run = run_workflow(repo, @release, "refs/tags/v1.2.0")
      {:ok, _} = Workflows.update_job_status(job(run, "build"), "success")

      assert Enum.all?(jobs(run, "release"), &(&1.status == "queued"))
      assert job(run, "create-release").status == "blocked"
      assert run_status(run) in ~w(queued running)
    end
  end

  test "a job without needs is skipped as the run is created", %{repo: repo} do
    run =
      run_workflow(
        repo,
        """
        name: ci
        on: push
        jobs:
          publish:
            if: ${{ startsWith(github.ref, 'refs/tags/v') }}
            strategy:
              matrix:
                os: [macos-latest, windows-latest]
            runs-on: ${{ matrix.os }}
            steps:
              - run: true
        """,
        "refs/heads/main"
      )

    assert Enum.all?(jobs(run, "publish"), &(&1.status == "skipped"))
    assert run_status(run) == "success"
  end

  test "a matrix value can decide a leg", %{repo: repo} do
    run =
      run_workflow(
        repo,
        """
        name: ci
        on: push
        jobs:
          test:
            if: matrix.os != 'macos-latest'
            strategy:
              matrix:
                os: [self-hosted, macos-latest]
            runs-on: ${{ matrix.os }}
            steps:
              - run: true
        """,
        "refs/heads/main"
      )

    assert %{"self-hosted" => "queued", "macos-latest" => "skipped"} ==
             Map.new(jobs(run, "test"), &{&1.matrix["os"], &1.status})
  end

  test "a true `if:` is queued", %{repo: repo} do
    run =
      run_workflow(
        repo,
        """
        name: ci
        on: push
        jobs:
          lint:
            if: ${{ github.ref == 'refs/heads/main' }}
            runs-on: self-hosted
            steps:
              - run: true
        """,
        "refs/heads/main"
      )

    assert job(run, "lint").status == "queued"
  end

  describe "what only the runner can decide is left to it" do
    for condition <- [
          "steps.x.outputs.y == 'z'",
          "always() && startsWith(github.ref, 'refs/tags/v')",
          "${{ success() && false }}",
          "env.DEPLOY == 'true'",
          "secrets.TOKEN != ''",
          "${{ github.ref }} == 'refs/tags/v1'"
        ] do
      test "if: #{condition}", %{repo: repo} do
        run =
          run_workflow(
            repo,
            """
            name: ci
            on: push
            jobs:
              build:
                if: #{inspect(unquote(condition))}
                runs-on: self-hosted
                steps:
                  - run: true
            """,
            "refs/heads/main"
          )

        assert job(run, "build").status == "queued"
      end
    end
  end

  test "re-running a job stuck queued decides its `if:` then", %{repo: repo} do
    run = run_workflow(repo, @release, "refs/heads/master")
    {:ok, _} = Workflows.update_job_status(job(run, "build"), "success")

    # As though it was queued before the forge decided `if:` itself.
    for leg <- jobs(run, "release") do
      leg |> Ecto.Changeset.change(status: "queued", status_reason: nil) |> Repo.update!()
    end

    run |> WorkflowRun.status_changeset("running") |> Repo.update!()
    job(run, "create-release") |> Ecto.Changeset.change(status: "blocked") |> Repo.update!()

    {:ok, _} = Workflows.rerun_job(Repo.get!(WorkflowRun, run.id), "release")

    assert Enum.all?(jobs(run, "release"), &(&1.status == "skipped"))
    assert job(run, "create-release").status == "skipped"
    assert run_status(run) == "success"
  end
end