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} = triggering_actor(run, owner, owner_id)
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" => sha_hex(run.head_sha),
"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_attempt(job)),
"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_key(job),
# `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
defp triggering_actor(run, owner, owner_id) do
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
end
defp sha_hex(nil), do: ""
defp sha_hex(sha), do: Git.to_hex(sha)
defp job_attempt(nil), do: 1
defp job_attempt(job), do: job.attempt || 1
defp job_key(nil), do: ""
defp job_key(job), do: job.job_id || ""
# 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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
9.1 KiB · text
History
b868907