Let repositories grant other repositories' CI jobs access, and dispatch

713c997 · gmorell · 2026-10-01 18:55

33 files +751 -14
Message
{commit_body(@commit)}

Files changed

modified PROGRESS.md
+26 −0
@@ -12,6 +12,32 @@ intermediate "I was here when I stopped" state) lives in the
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,6 +65,63 @@ defmodule GitGud.Workflows do
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,7 +33,13 @@ defmodule GitGud.Workflows.JobTokenGrant do
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,18 +55,34 @@ defmodule GitGud.Workflows.JobTokenGrant do
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,6 +565,11 @@ defmodule GitGudWeb.CoreComponents do
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
@@ -0,0 +1,115 @@
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