8.9 KiB · text History a5b815d
defmodule GitGud.Workflows.Context do
  @moduledoc """
  The expression contexts of a run — `github`, `inputs`, `vars`, and for a
  job `matrix` and `needs` — built from what the run stores.

  `github/3` is also the runner's context (`RunnerController.build_task/1`
  adds the job token to it and sends it), so what the forge evaluates a
  `concurrency:` group against and what the job later reads as
  `${{ github.* }}` can't disagree.
  """

  alias GitGud.Accounts.User
  alias GitGud.Git
  alias GitGud.Repo
  alias GitGud.Repositories.Repository
  alias GitGud.Repositories.Storage
  alias GitGud.Workflows.Ref
  alias GitGud.Workflows.WorkflowJob
  alias GitGud.Workflows.WorkflowRun

  @doc "The contexts a workflow-level expression can read."
  def for_run(%WorkflowRun{} = run, %Repository{} = repo) do
    %{
      "github" => github(run, repo),
      "inputs" => inputs(run),
      "vars" => vars(run, repo)
    }
  end

  @doc "The contexts a job-level expression can read, `needs` from its snapshot."
  def for_job(%WorkflowJob{} = job, %WorkflowRun{} = run, %Repository{} = repo) do
    %{
      "github" => github(run, repo, job),
      "inputs" => inputs(run),
      "vars" => vars(run, repo),
      "matrix" => job.matrix || %{},
      "needs" =>
        Map.new(job.needs_snapshot || %{}, fn {jid, need} ->
          {jid, %{"result" => need["status"], "outputs" => need["outputs"] || %{}}}
        end)
    }
  end

  @doc """
  `github.*`, without the job token. Keys and meanings follow Forgejo's
  server (`services/actions/context.go`), which is what forgejo-runner is
  written against. `job` is nil for a workflow-level expression, which has
  no `github.job`.
  """
  def github(%WorkflowRun{} = run, %Repository{} = repo, job \\ nil) do
    repo = Repo.preload(repo, [:owner, :organization])
    base = GitGudWeb.Endpoint.url()

    # Where the runner resolves `uses: actions/checkout@v4` etc.
    # Default to code.forgejo.org which curates a mirror of the
    # common GitHub Actions. Override via:
    #   config :git_gud, :default_actions_url, "https://github.com"
    # or env GITGUD_DEFAULT_ACTIONS_URL in runtime.exs.
    actions_url =
      Application.get_env(:git_gud, :default_actions_url, "https://code.forgejo.org")

    # GITHUB_REPOSITORY must be the fully-qualified `owner/repo` —
    # actions/checkout rejects a bare repo name ("Invalid repository
    # 'gitgud'. Expected format {owner}/{repo}."). `repo_handle/1`
    # resolves the org or user namespace.
    owner = Storage.repo_handle(repo)

    # `github.actor` / `github.triggering_actor`: the pusher's handle when
    # attributable, else the repo owner. Without this, `${{ github.actor }}`
    # is empty and steps like `docker/login-action` fail with
    # "Username required".
    owner_id = repo.organization_id || repo.owner_id

    {actor, actor_id} =
      case run.triggered_by_id && Repo.get(User, run.triggered_by_id) do
        %User{handle: h, id: id} when is_binary(h) and h != "" -> {h, id}
        _ -> {owner, owner_id}
      end

    full_name = owner <> "/" <> repo.name

    %{
      # The event *payload* — forgejo-runner reads it with
      # `GetStructValue()`, so a string here (as before) reads as empty and
      # every `github.event.*` is blank. Runs from before the payload was
      # stored send an empty object.
      "event" => run.event_payload || %{},
      # `github.event_name`, which is what workflows compare against —
      # `if: github.event_name == 'push'` read it as empty and skipped the
      # job on every push.
      "event_name" => run.trigger || "push",
      "ref" => run.head_ref || "",
      # `github.ref_name` / `github.ref_type`, and the GITHUB_REF_NAME and
      # GITHUB_REF_TYPE the runner exports from them. Every workflow written
      # against GitHub's documentation reaches for these, and their absence
      # does not fail — it reads as empty, which is how a release workflow
      # here took its "not a tag" branch on a tag push, published nothing, and
      # reported success. A CI failure that claims to have worked is the
      # expensive kind.
      "ref_name" => Ref.name(run.head_ref),
      "ref_type" => Ref.type(run.head_ref),
      "sha" => if(run.head_sha, do: Git.to_hex(run.head_sha), else: ""),
      "repository" => full_name,
      "repository_id" => Integer.to_string(repo.id),
      "repository_owner" => owner,
      "repository_owner_id" => maybe_int_to_string(owner_id),
      "actor" => actor,
      "actor_id" => maybe_int_to_string(actor_id),
      "triggering_actor" => actor,
      # What `actions/upload-artifact` builds its URL from, and the artifact
      # endpoints check against the token's run.
      "run_id" => Integer.to_string(run.id),
      "run_number" => maybe_int_to_string(run.number),
      "run_attempt" => maybe_int_to_string((job && job.attempt) || 1),
      "workflow_ref" => full_name <> "/" <> run.workflow_path <> "@" <> (run.head_ref || ""),
      "server_url" => base,
      "api_url" => base <> "/api/v1",
      # Forgejo sends it empty too: artifacts follow the instance's policy.
      "retention_days" => "",
      "workflow" => run.workflow_name || run.workflow_path,
      "job" => (job && job.job_id) || "",
      # `gitea_default_actions_url` tells the runner where to clone
      # `uses: actions/<repo>@<ref>` from. If we point at our own
      # instance, the runner expects `actions/checkout` etc. to be
      # hosted here — which we don't do. Pointing at code.forgejo.org
      # makes references like `actions/checkout@v4` resolve to the
      # Forgejo-curated mirror; for GitHub-native references use
      # `https://github.com`.
      "gitea_default_actions_url" => actions_url,
      "forgejo_default_actions_url" => actions_url,
      "ACTIONS_RUNTIME_URL" => base <> "/api/actions/_apis/",
      "ACTIONS_CACHE_URL" => base <> "/api/actions/_apis/artifactcache/"
    }
    |> Map.merge(pull_request_context(run))
  end

  # What GitHub gives a `pull_request` job. `ref` is already the run's
  # `head_ref` (`refs/pull/<N>/head`); its short name is `<N>/head`, and
  # `ref_type` is empty, as on GitHub — a PR ref is neither a branch nor a
  # tag. `head_ref` / `base_ref` are the PR's source and target branches.
  defp pull_request_context(%{trigger: "pull_request"} = run) do
    %{
      "ref_name" => pull_ref_name(run),
      "ref_type" => "",
      "head_ref" => run.pr_head_ref || "",
      "base_ref" => run.pr_base_ref || ""
    }
  end

  # A `pull_request_target` job runs on the target branch, so `ref`,
  # `ref_name` and `ref_type` are that branch's; `head_ref` / `base_ref`
  # are the PR's, as for `pull_request`.
  defp pull_request_context(%{trigger: "pull_request_target"} = run) do
    %{"head_ref" => run.pr_head_ref || "", "base_ref" => run.pr_base_ref || ""}
  end

  defp pull_request_context(_run), do: %{}

  defp pull_ref_name(%{head_ref: "refs/pull/" <> name}), do: name
  defp pull_ref_name(%{pull_request_number: n}) when is_integer(n), do: "#{n}/head"
  defp pull_ref_name(_run), do: ""

  defp maybe_int_to_string(n) when is_integer(n), do: Integer.to_string(n)
  defp maybe_int_to_string(_), do: ""

  @doc """
  `inputs.*`: a manual run's inputs over the defaults its
  `on.workflow_dispatch.inputs` declares. Empty for other events.
  """
  def inputs(%WorkflowRun{trigger: "workflow_dispatch"} = run) do
    defaults =
      case run.workflow_doc do
        %{"on" => %{"workflow_dispatch" => %{"inputs" => %{} = declared}}} ->
          for {name, %{"default" => default}} <- declared, into: %{}, do: {name, default}

        _ ->
          %{}
      end

    Map.merge(defaults, (run.event_payload || %{})["inputs"] || %{})
  end

  def inputs(_run), do: %{}

  @doc """
  `vars.*`: the built-ins, overridden by org and then repository variables.
  A fork run (`run.fork`) gets the built-ins only — the forge evaluates
  its `concurrency:` and matrix expressions, and their results end up in
  job names anyone can read.
  """
  def vars(%WorkflowRun{fork: true}, %Repository{}), do: builtin_vars()

  def vars(%WorkflowRun{}, %Repository{} = repo) do
    {_secrets, vars} = GitGud.Secrets.resolve_for(repo)
    Map.merge(builtin_vars(), vars)
  end

  # Built-in CI variables the forge injects into every job.
  #
  #   * CI_REGISTRY — the bundled OCI registry host (config :git_gud,
  #     :registry_host, from GITGUD_REGISTRY_HOST) so workflows can
  #     `docker login`/push without per-repo config. nil host → not
  #     injected.
  #   * CI_FORGE_URL — this forge's own base URL, so a workflow can
  #     reach the package registries (`/pkg/...`) and the REST API with
  #     the per-job token and no per-repo config either.
  #
  # Both are overridable: `CI_*` is deliberately not a reserved prefix.
  @doc false
  def builtin_vars do
    registry =
      case Application.get_env(:git_gud, :registry_host) do
        host when is_binary(host) and host != "" -> %{"CI_REGISTRY" => host}
        _ -> %{}
      end

    Map.put(registry, "CI_FORGE_URL", GitGudWeb.Endpoint.url())
  end
end