12.6 KiB · text History b868907
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