neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Let repositories grant other repositories' CI jobs access, and dispatch
713c997 · gmorell · 2026-10-01 18:55
Message
{commit_body(@commit)}
Files changed
modified
PROGRESS.md
+26
−0
| 12 | 12 | |
| 13 | 13 | --- |
| 14 | 14 | |
| 15 | +## Slice 76 — Registry access, cross-repo CI, and a runner protocol fix | |
| 16 | + | |
| 17 | +Driven by moving a multi-image project (game client, login server, demo, | |
| 18 | +plus a separate site repo that consumes the demo image) off GitLab CI: | |
| 19 | +each gap below was a wall that pipeline hit. | |
| 20 | + | |
| 21 | +### One access rule for the registries | |
| 22 | + | |
| 23 | +- **`GitGud.Packages.Access`** decides for both the OCI token endpoint and the package registries, which had each decided separately and disagreed with the UI and with each other. | |
| 24 | +- **Images under a repository**: `owner/repo/server` belongs to `owner/repo`. Every credential was refused anything past `owner/repo`, so a repository could publish only one image. | |
| 25 | +- **Anonymous no longer pulls internal images.** Internal means signed-in users, as `Organizations.permission/2` already said. | |
| 26 | +- **Org admins and teams count** in the registries: accounts get what `Organizations.can?/3` gives them. Deleting an image takes admin. A plain org member no longer publishes to a private repository's package registry without a team granting write. | |
| 27 | +- **Personal tokens keep to their scopes**: a `ci:read` token could publish packages. Adds `packages:write`. Personal tokens are accepted at `/v2/token` too. | |
| 28 | +- The OCI token endpoint had no tests; it has them now. | |
| 29 | + | |
| 30 | +### Cross-repository CI | |
| 31 | + | |
| 32 | +- **Job-token grants** (repo settings → Job token access): a repository lets another repository's — or every repository in an organization's — job tokens read its images and packages and/or dispatch its workflows. GitLab's job-token allowlist; the repository reached into decides. | |
| 33 | +- **`workflow_dispatch`**: `POST /api/v1/repos/:owner/:name/actions/workflows/:workflow/dispatches`, Gitea/GitHub-compatible, with a job token (own repo or granted) or a `ci:write` personal token. The workflow must declare `on: workflow_dispatch`. `inputs` are accepted and not yet passed through. | |
| 34 | + | |
| 35 | +### Runner protocol | |
| 36 | + | |
| 37 | +- **Field numbers now match forgejo-runner's** (`actions-proto-def`) for `Runner`, `RegisterRequest` and `TaskNeed`. A runner read our `version` as its labels and our `token` as its name ("runner: , with version: , with labels: [v13.2.0]"); a binary-protobuf registration would have crossed version and labels. Tests check against bytes laid out by upstream's numbering. | |
| 38 | + | |
| 39 | +--- | |
| 40 | + | |
| 15 | 41 | ## Slice 75 — Package retention, and where the disk went |
| 16 | 42 | |
| 17 | 43 | Storage only grew. Yanking deliberately keeps the bytes so existing |
modified
lib/git_gud/workflows.ex
+57
−0
| 65 | 65 | {:ok, runs} |
| 66 | 66 | end |
| 67 | 67 | |
| 68 | + @doc """ | |
| 69 | + Run one workflow on demand: GitHub's `workflow_dispatch`. | |
| 70 | + | |
| 71 | + `workflow` names the file, by basename (`build.yml`) or path | |
| 72 | + (`.forgejo/workflows/build.yml`); `ref` is a branch or tag, short | |
| 73 | + (`main`, `v1.2`) or full (`refs/heads/main`). The workflow has to opt in | |
| 74 | + with `on: workflow_dispatch`, as on GitHub, so a file can't be run by | |
| 75 | + someone who merely knows its name. | |
| 76 | + | |
| 77 | + Returns `{:ok, run}`, or `{:error, :unknown_ref | :unknown_workflow | | |
| 78 | + :not_dispatchable}`. | |
| 79 | + """ | |
| 80 | + def dispatch_manual(%Repository{} = repo, workflow, ref, triggered_by_id \\ nil) | |
| 81 | + when is_binary(workflow) and is_binary(ref) do | |
| 82 | + with {:ok, full_ref, sha} <- resolve_ref(repo, ref), | |
| 83 | + {:ok, path, doc} <- find_workflow(repo, sha, workflow), | |
| 84 | + true <- | |
| 85 | + Yaml.matches?(doc, "workflow_dispatch", Ref.context(full_ref)) || | |
| 86 | + {:error, :not_dispatchable} do | |
| 87 | + create_run(repo, %{ | |
| 88 | + workflow_path: path, | |
| 89 | + workflow_name: doc.name, | |
| 90 | + trigger: "workflow_dispatch", | |
| 91 | + head_sha: sha, | |
| 92 | + head_ref: full_ref, | |
| 93 | + workflow_doc: doc.raw, | |
| 94 | + triggered_by_id: triggered_by_id, | |
| 95 | + jobs: doc.jobs | |
| 96 | + }) | |
| 97 | + end | |
| 98 | + end | |
| 99 | + | |
| 100 | + defp resolve_ref(%Repository{disk_path: path} = repo, ref) do | |
| 101 | + candidates = | |
| 102 | + if String.starts_with?(ref, "refs/"), | |
| 103 | + do: [ref], | |
| 104 | + else: ["refs/heads/" <> ref, "refs/tags/" <> ref] | |
| 105 | + | |
| 106 | + with {:ok, refs} <- Git.list_refs(path), | |
| 107 | + %{name: name, target_sha: sha} <- | |
| 108 | + Enum.find_value(candidates, fn c -> Enum.find(refs, &(&1.name == c)) end) do | |
| 109 | + {:ok, name, peel(repo, name, sha)} | |
| 110 | + else | |
| 111 | + _ -> {:error, :unknown_ref} | |
| 112 | + end | |
| 113 | + end | |
| 114 | + | |
| 115 | + defp find_workflow(repo, sha, workflow) do | |
| 116 | + repo | |
| 117 | + |> discover_workflows(sha) | |
| 118 | + |> Enum.find(fn {path, _doc} -> path == workflow or Path.basename(path) == workflow end) | |
| 119 | + |> case do | |
| 120 | + {path, doc} -> {:ok, path, doc} | |
| 121 | + nil -> {:error, :unknown_workflow} | |
| 122 | + end | |
| 123 | + end | |
| 124 | + | |
| 68 | 125 | # An annotated tag's ref points at a *tag object*, not at a commit, and |
| 69 | 126 | # everything downstream wants the commit: `discover_workflows` reads the |
| 70 | 127 | # tree, the run records `head_sha`, and the runner checks it out. Before this, |
modified
lib/git_gud/workflows/job_token_grant.ex
+29
−7
| 33 | 33 | @doc false |
| 34 | 34 | def changeset(grant, attrs) do |
| 35 | 35 | grant |
| 36 | − |> cast(attrs, [:repository_id, :source_repository_id, :source_organization_id, :read, :dispatch]) | |
| 36 | + |> cast(attrs, [ | |
| 37 | + :repository_id, | |
| 38 | + :source_repository_id, | |
| 39 | + :source_organization_id, | |
| 40 | + :read, | |
| 41 | + :dispatch | |
| 42 | + ]) | |
| 37 | 43 | |> validate_required([:repository_id]) |
| 38 | 44 | |> validate_one_source() |
| 39 | 45 | |> validate_grants_something() |
| 49 | 55 | end |
| 50 | 56 | |
| 51 | 57 | defp validate_one_source(changeset) do |
| 52 | − case {get_field(changeset, :source_repository_id), get_field(changeset, :source_organization_id)} do | |
| 53 | − {nil, nil} -> add_error(changeset, :source_repository_id, "name a repository or an organization") | |
| 54 | − {_, nil} -> validate_not_self(changeset) | |
| 55 | − {nil, _} -> changeset | |
| 56 | − _ -> add_error(changeset, :source_repository_id, "name a repository or an organization, not both") | |
| 58 | + case {get_field(changeset, :source_repository_id), | |
| 59 | + get_field(changeset, :source_organization_id)} do | |
| 60 | + {nil, nil} -> | |
| 61 | + add_error(changeset, :source_repository_id, "name a repository or an organization") | |
| 62 | + | |
| 63 | + {_, nil} -> | |
| 64 | + validate_not_self(changeset) | |
| 65 | + | |
| 66 | + {nil, _} -> | |
| 67 | + changeset | |
| 68 | + | |
| 69 | + _ -> | |
| 70 | + add_error( | |
| 71 | + changeset, | |
| 72 | + :source_repository_id, | |
| 73 | + "name a repository or an organization, not both" | |
| 74 | + ) | |
| 57 | 75 | end |
| 58 | 76 | end |
| 59 | 77 | |
| 60 | 78 | # A repository's own jobs already have full access to it. |
| 61 | 79 | defp validate_not_self(changeset) do |
| 62 | 80 | if get_field(changeset, :source_repository_id) == get_field(changeset, :repository_id) do |
| 63 | − add_error(changeset, :source_repository_id, "is this repository; its jobs already have access") | |
| 81 | + add_error( | |
| 82 | + changeset, | |
| 83 | + :source_repository_id, | |
| 84 | + "is this repository; its jobs already have access" | |
| 85 | + ) | |
| 64 | 86 | else |
| 65 | 87 | changeset |
| 66 | 88 | end |
modified
lib/git_gud_web/components/core_components.ex
+5
−0
| 565 | 565 | label="Registry tokens" |
| 566 | 566 | active={@current == :registry_tokens} |
| 567 | 567 | /> |
| 568 | + <.repo_settings_nav_link | |
| 569 | + navigate={~p"/r/#{@handle}/#{@repo.name}/settings/job-tokens"} | |
| 570 | + label="Job token access" | |
| 571 | + active={@current == :job_tokens} | |
| 572 | + /> | |
| 568 | 573 | <.repo_settings_nav_link |
| 569 | 574 | navigate={~p"/r/#{@handle}/#{@repo.name}/settings/interaction"} |
| 570 | 575 | label="Interaction policy" |
added
lib/git_gud_web/controllers/api/v1/workflow_controller.ex
+115
−0
| 1 | +defmodule GitGudWeb.Api.V1.WorkflowController do | |
| 2 | + @moduledoc """ | |
| 3 | + `workflow_dispatch`, Gitea/GitHub-compatible: | |
| 4 | + | |
| 5 | + POST /api/v1/repos/:owner/:name/actions/workflows/:workflow/dispatches | |
| 6 | + Authorization: token <credential> | |
| 7 | + { "ref": "main" } | |
| 8 | + | |
| 9 | + `:workflow` is the file's basename (`build.yml`) or its path with the | |
| 10 | + slashes percent-encoded (`.forgejo%2Fworkflows%2Fbuild.yml`); the workflow must declare | |
| 11 | + `on: workflow_dispatch`. Answers 204 with no body, like GitHub. | |
| 12 | + | |
| 13 | + Accepted credentials: | |
| 14 | + | |
| 15 | + * a CI job's `${{ github.token }}` — from this repository, or from one | |
| 16 | + this repository's job-token grants allow to dispatch | |
| 17 | + (`GitGud.Workflows.JobTokenGrants`). This is how one repository's | |
| 18 | + pipeline kicks off another's. | |
| 19 | + * a personal access token with `ci:write`, whose account may write to | |
| 20 | + the repository. | |
| 21 | + | |
| 22 | + `inputs` are accepted for compatibility and not yet passed to the run. | |
| 23 | + """ | |
| 24 | + use GitGudWeb, :controller | |
| 25 | + | |
| 26 | + alias GitGud.Accounts | |
| 27 | + alias GitGud.Organizations | |
| 28 | + alias GitGud.Repositories | |
| 29 | + alias GitGud.Workflows | |
| 30 | + alias GitGud.Workflows.JobTokenGrants | |
| 31 | + alias GitGud.Workflows.RunnerJwt | |
| 32 | + | |
| 33 | + def dispatch(conn, %{"owner" => owner, "name" => name, "workflow" => workflow} = params) do | |
| 34 | + with {:ok, repo} <- fetch_repo(owner, name), | |
| 35 | + {:ok, actor_id} <- authorize(conn, repo), | |
| 36 | + ref when is_binary(ref) and ref != "" <- | |
| 37 | + params["ref"] || repo.default_branch || {:error, :no_ref}, | |
| 38 | + {:ok, _run} <- Workflows.dispatch_manual(repo, workflow, ref, actor_id) do | |
| 39 | + send_resp(conn, 204, "") | |
| 40 | + else | |
| 41 | + {:error, :not_found} -> | |
| 42 | + send_error(conn, 404, "repository not found") | |
| 43 | + | |
| 44 | + {:error, :unauthenticated} -> | |
| 45 | + send_error(conn, 401, "a job token or a ci:write personal token is required") | |
| 46 | + | |
| 47 | + {:error, :forbidden} -> | |
| 48 | + send_error(conn, 403, "not allowed to dispatch this repository's workflows") | |
| 49 | + | |
| 50 | + {:error, :no_ref} -> | |
| 51 | + send_error(conn, 422, "ref is required") | |
| 52 | + | |
| 53 | + {:error, :unknown_ref} -> | |
| 54 | + send_error(conn, 422, "no such branch or tag: #{params["ref"]}") | |
| 55 | + | |
| 56 | + {:error, :unknown_workflow} -> | |
| 57 | + send_error(conn, 404, "no such workflow: #{workflow}") | |
| 58 | + | |
| 59 | + {:error, :not_dispatchable} -> | |
| 60 | + send_error(conn, 422, "#{workflow} has no workflow_dispatch trigger") | |
| 61 | + | |
| 62 | + {:error, reason} -> | |
| 63 | + send_error(conn, 500, inspect(reason)) | |
| 64 | + end | |
| 65 | + end | |
| 66 | + | |
| 67 | + defp fetch_repo(owner, name) do | |
| 68 | + {:ok, Repositories.get_repository_by_path!(owner, name)} | |
| 69 | + rescue | |
| 70 | + Ecto.NoResultsError -> {:error, :not_found} | |
| 71 | + end | |
| 72 | + | |
| 73 | + # `{:ok, triggered_by_id}` — the account to attribute the run to, or nil | |
| 74 | + # for a job (a pipeline, not a person, asked for it). | |
| 75 | + defp authorize(conn, repo) do | |
| 76 | + case token(conn) do | |
| 77 | + nil -> | |
| 78 | + {:error, :unauthenticated} | |
| 79 | + | |
| 80 | + token -> | |
| 81 | + case RunnerJwt.verify(token) do | |
| 82 | + {:ok, %{"pid" => source_id}} when is_integer(source_id) -> | |
| 83 | + if JobTokenGrants.allows?(repo, source_id, :dispatch), | |
| 84 | + do: {:ok, nil}, | |
| 85 | + else: {:error, :forbidden} | |
| 86 | + | |
| 87 | + _ -> | |
| 88 | + case Accounts.verify_personal_access_token(token) do | |
| 89 | + {:ok, user, scopes} -> | |
| 90 | + if "ci:write" in scopes and Organizations.can?(user, repo, :write) == true, | |
| 91 | + do: {:ok, user.id}, | |
| 92 | + else: {:error, :forbidden} | |
| 93 | + | |
| 94 | + :error -> | |
| 95 | + {:error, :unauthenticated} | |
| 96 | + end | |
| 97 | + end | |
| 98 | + end | |
| 99 | + end | |
| 100 | + | |
| 101 | + # `token …` (Gitea) and `Bearer …` (GitHub), any case. | |
| 102 | + defp token(conn) do | |
| 103 | + with [header | _] <- get_req_header(conn, "authorization"), | |
| 104 | + [scheme, token] <- String.split(header, " ", parts: 2), | |
| 105 | + true <- String.downcase(scheme) in ["token", "bearer"] do | |
| 106 | + String.trim(token) | |
| 107 | + else | |
| 108 | + _ -> nil | |
| 109 | + end | |
| 110 | + end | |
| 111 | + | |
| 112 | + defp send_error(conn, status, message) do | |
| 113 | + conn |> put_status(status) |> json(%{"message" => message}) | |
| 114 | + end | |
| 115 | +end |
added
lib/git_gud_web/live/job_token_live/index.ex
+175
−0
Click to load diff…
modified
lib/git_gud_web/router.ex
+7
−0
Click to load diff…
modified
priv/repo/migrations/20261001150000_create_job_token_grants.exs
+1
−2
Click to load diff…
modified
test/git_gud/packages/access_test.exs
+3
−1
Click to load diff…
added
test/git_gud_web/controllers/api/v1/workflow_controller_test.exs
+244
−0
Click to load diff…
modified
test/git_gud_web/controllers/registry_controller_test.exs
+18
−4
Click to load diff…
added
test/git_gud_web/live/job_token_live_test.exs
+71
−0
Click to load diff…
Parents: c4d7080