Runners: a user can register runners for their own repositories

003392d · Gabriel Morell · 2026-11-01 23:22

35 files +1054 -80
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/accounts/user.ex
+5 −0
@@ -38,6 +38,11 @@ defmodule GitGud.Accounts.User do
38 38 field :totp_last_step, :integer
39 39 field :second_factor_reauth_enabled, :boolean, default: false
40 40
41 + # Registers one runner of the user's own, for their personal
42 + # repositories. See `GitGud.Repositories.Repository.runner_reg_token_hash`.
43 + field :runner_reg_token_hash, :binary, redact: true
44 + field :runner_reg_token_expires_at, :utc_datetime
45 +
41 46 timestamps(type: :utc_datetime)
42 47 end
43 48
modified lib/git_gud/workflows.ex
+14 −0
@@ -1719,7 +1719,12 @@ defmodule GitGud.Workflows do
1719 1719 # A runner only claims jobs in its scope:
1720 1720 # * repo-scoped runner → jobs in that repo
1721 1721 # * org-scoped runner → jobs in any repo owned by that org
1722 + # * a user's own runner → jobs in the repos that user owns
1723 + # personally; an org repo's `owner_id` is
1724 + # only its creator, so not those
1722 1725 # * global runner → any job
1726 + # Fork runs waiting on approval are kept out after this, whatever the
1727 + # scope (`approved_only/1`).
1723 1728 defp scoped_job_query(%Runner{repository_id: rid}) when is_integer(rid) do
1724 1729 from j in WorkflowJob,
1725 1730 join: r in WorkflowRun,
@@ -1736,6 +1741,15 @@ defmodule GitGud.Workflows do
1736 1741 where: repo.organization_id == ^oid
1737 1742 end
1738 1743
1744 + defp scoped_job_query(%Runner{owner_id: uid}) when is_integer(uid) do
1745 + from j in WorkflowJob,
1746 + join: r in WorkflowRun,
1747 + on: r.id == j.workflow_run_id,
1748 + join: repo in Repository,
1749 + on: repo.id == r.repository_id,
1750 + where: repo.owner_id == ^uid and is_nil(repo.organization_id)
1751 + end
1752 +
1739 1753 defp scoped_job_query(_global) do
1740 1754 from(j in WorkflowJob)
1741 1755 end
modified lib/git_gud/workflows/runner.ex
+6 −1
@@ -30,6 +30,9 @@ defmodule GitGud.Workflows.Runner do
30 30 field :last_seen_at, :utc_datetime
31 31 field :status, :string, default: "active"
32 32
33 + # A user's own runner: takes jobs from the repositories they own
34 + # personally, not their organizations'. At most one of owner,
35 + # repository and organization is set; none is a global runner.
33 36 belongs_to :owner, User
34 37 belongs_to :repository, Repository
35 38 belongs_to :organization, Organization
@@ -47,6 +50,7 @@ defmodule GitGud.Workflows.Runner do
47 50 :status,
48 51 :repository_id,
49 52 :organization_id,
53 + :owner_id,
50 54 :uuid
51 55 ])
52 56 |> validate_required([:name])
@@ -54,7 +58,7 @@ defmodule GitGud.Workflows.Runner do
54 58 |> validate_length(:name, max: 100)
55 59 |> check_constraint(:base,
56 60 name: :runners_scope_xor,
57 − message: "runner may be scoped to either a repository or an organization, not both"
61 + message: "runner may be scoped to one of a user, a repository or an organization"
58 62 )
59 63 end
60 64
@@ -71,6 +75,7 @@ defmodule GitGud.Workflows.Runner do
71 75 @doc "Human label for the runner's scope."
72 76 def scope_label(%__MODULE__{repository_id: r}) when not is_nil(r), do: "repo"
73 77 def scope_label(%__MODULE__{organization_id: o}) when not is_nil(o), do: "org"
78 + def scope_label(%__MODULE__{owner_id: u}) when not is_nil(u), do: "user"
74 79 def scope_label(_), do: "global"
75 80
76 81 def valid_statuses, do: @valid_statuses
modified lib/git_gud/workflows/runners.ex
+58 −52
@@ -5,15 +5,21 @@ defmodule GitGud.Workflows.Runners do
5 5 Two registration paths:
6 6
7 7 1. **Per-scope token** — repo or org admins mint a token via the
8 − settings UI; the resulting runner is scoped to that repo or org.
9 − Stored as a SHA-256 hash on `repositories.runner_reg_token_hash`
10 − or `organizations.runner_reg_token_hash`. Regenerating shifts the
11 − hash, invalidating the old token in one step. A token registers
12 − one runner and expires a day after it's minted.
8 + settings UI, and a user for their own repositories on
9 + /users/settings/runners; the resulting runner is scoped to that
10 + repo, org or user. Stored as a SHA-256 hash on the
11 + `runner_reg_token_hash` of `repositories`, `organizations` or
12 + `users`. Regenerating shifts the hash, invalidating the old token
13 + in one step. A token registers one runner and expires a day after
14 + it's minted.
13 15
14 16 2. **Global env token** (`GITGUD_RUNNER_REG_TOKEN`) — legacy path
15 − for operator-managed pool runners. Resulting runner has neither
16 − repository_id nor organization_id set and serves any repo's jobs.
17 + for operator-managed pool runners. Resulting runner has no
18 + repository, organization or owner and serves any repo's jobs.
19 +
20 + A user's runner (`owner_id`) serves the repositories that user owns
21 + personally — not those of an organization they belong to, though an
22 + org repository's `owner_id` is its creator's.
17 23
18 24 Runner-side tokens (issued by `register/2`) are random 32 bytes; we
19 25 store only the SHA-256 hash. The bare token is shown once and never
@@ -22,6 +28,7 @@ defmodule GitGud.Workflows.Runners do
22 28
23 29 import Ecto.Query, warn: false
24 30
31 + alias GitGud.Accounts.User
25 32 alias GitGud.Organizations.Organization
26 33 alias GitGud.Repo
27 34 alias GitGud.Repositories.Repository
@@ -52,6 +59,7 @@ defmodule GitGud.Workflows.Runners do
52 59 * `{:ok, :global}` — legacy global token from env / config
53 60 * `{:ok, %Repository{}}` — repo-scoped token
54 61 * `{:ok, %Organization{}}` — org-scoped token
62 + * `{:ok, %User{}}` — a user's own token
55 63 * `:error` — unknown, expired or already used
56 64
57 65 A scoped token is good for one runner, within
@@ -76,6 +84,9 @@ defmodule GitGud.Workflows.Runners do
76 84 org = redeem(Organization, token) ->
77 85 {:ok, org}
78 86
87 + user = redeem(User, token) ->
88 + {:ok, user}
89 +
79 90 true ->
80 91 :error
81 92 end
@@ -164,7 +175,8 @@ defmodule GitGud.Workflows.Runners do
164 175 Regenerating is destructive — any previously-distributed token for
165 176 that scope stops working immediately.
166 177 """
167 − def mint_registration_token(%scope{} = record) when scope in [Repository, Organization] do
178 + def mint_registration_token(%scope{} = record)
179 + when scope in [Repository, Organization, User] do
168 180 token = mint_token()
169 181 expires_at = DateTime.add(DateTime.utc_now(:second), registration_token_ttl_seconds())
170 182
@@ -194,7 +206,7 @@ defmodule GitGud.Workflows.Runners do
194 206 @doc """
195 207 Register a new runner. `attrs` must include `:name`. `scope` is the
196 208 value returned by `consume_registration_token/1` and decides whether
197 − the runner ends up repo-scoped, org-scoped, or global.
209 + the runner ends up repo-scoped, org-scoped, a user's, or global.
198 210
199 211 Returns `{:ok, runner, bare_token}` on success; bare token is the
200 212 runner's bearer credential, shown once.
@@ -229,6 +241,8 @@ defmodule GitGud.Workflows.Runners do
229 241 @doc "Where a runner of `scope` belongs in the audit log."
230 242 def audit_scope(%Repository{} = repo), do: %{repository: repo}
231 243 def audit_scope(%Organization{} = org), do: %{organization: org}
244 + # A user's runner is about them: their security log.
245 + def audit_scope(%User{} = user), do: %{subject: user}
232 246 def audit_scope(_global), do: %{}
233 247
234 248 @doc "A runner's advertised labels, as a list."
@@ -238,6 +252,7 @@ defmodule GitGud.Workflows.Runners do
238 252 defp scope_attrs(:global), do: %{}
239 253 defp scope_attrs(%Repository{id: id}), do: %{repository_id: id}
240 254 defp scope_attrs(%Organization{id: id}), do: %{organization_id: id}
255 + defp scope_attrs(%User{id: id}), do: %{owner_id: id}
241 256 defp scope_attrs(_), do: %{}
242 257
243 258 @doc """
@@ -424,21 +439,24 @@ defmodule GitGud.Workflows.Runners do
424 439
425 440 @doc """
426 441 The PubSub topic a runners page follows for the runners of `scope`:
427 − a repository's, an organization's, the global ones (`:global`), or
428 − every runner on the instance (`:all`).
442 + a repository's, an organization's, a user's own, the global ones
443 + (`:global`), or every runner on the instance (`:all`).
429 444 """
430 445 def topic(%Repository{id: id}), do: "runners:repo:#{id}"
431 446 def topic(%Organization{id: id}), do: "runners:org:#{id}"
447 + def topic(%User{id: id}), do: "runners:user:#{id}"
432 448 def topic(:global), do: "runners:global"
433 449 def topic(:all), do: "runners:all"
434 450
435 451 defp topic_for(%Runner{repository_id: rid}) when is_integer(rid), do: "runners:repo:#{rid}"
436 452 defp topic_for(%Runner{organization_id: oid}) when is_integer(oid), do: "runners:org:#{oid}"
453 + defp topic_for(%Runner{owner_id: uid}) when is_integer(uid), do: "runners:user:#{uid}"
437 454 defp topic_for(%Runner{}), do: topic(:global)
438 455
439 456 @doc """
440 457 Delete a runner. Caller is responsible for authorization (repo admin
441 − for repo-scoped, org admin for org-scoped, app admin for global).
458 + for repo-scoped, org admin for org-scoped, the user for their own,
459 + app admin for global).
442 460 """
443 461 def delete_runner(%Runner{} = runner), do: Repo.delete(runner)
444 462
@@ -455,62 +473,50 @@ defmodule GitGud.Workflows.Runners do
455 473 List runners visible at `scope`.
456 474
457 475 * `%Repository{}` — runners scoped to that repo + that repo's org
458 − (if any) + global runners
476 + (if any), or its owner's own runners for a personal repo + global
477 + runners
459 478 * `%Organization{}` — runners scoped to that org + global
479 + * `%User{}` — that user's own runners + global
460 480 * `:global` — only global runners
461 481 """
462 − def list_for_scope(%Repository{id: rid, organization_id: oid}) do
463 − base =
464 − from r in Runner,
465 − order_by: [desc: r.last_seen_at]
466 −
467 − cond do
468 − is_integer(oid) ->
469 − Repo.all(
470 − from r in base,
471 − where:
472 − r.repository_id == ^rid or
473 − r.organization_id == ^oid or
474 − (is_nil(r.repository_id) and is_nil(r.organization_id))
475 − )
476 −
477 − true ->
478 − Repo.all(
479 − from r in base,
480 − where:
481 − r.repository_id == ^rid or
482 − (is_nil(r.repository_id) and is_nil(r.organization_id))
483 − )
484 − end
482 + def list_for_scope(%Repository{id: rid, organization_id: oid}) when is_integer(oid) do
483 + from(r in Runner, where: r.repository_id == ^rid or r.organization_id == ^oid)
484 + |> or_global()
485 485 end
486 486
487 − def list_for_scope(%Organization{id: oid}) do
488 − Repo.all(
489 − from r in Runner,
490 − where:
491 − r.organization_id == ^oid or
492 − (is_nil(r.repository_id) and is_nil(r.organization_id)),
493 − order_by: [desc: r.last_seen_at]
494 − )
487 + def list_for_scope(%Repository{id: rid, owner_id: uid}) when is_integer(uid) do
488 + from(r in Runner, where: r.repository_id == ^rid or r.owner_id == ^uid)
489 + |> or_global()
495 490 end
496 491
497 − def list_for_scope(:global) do
498 − Repo.all(
499 − from r in Runner,
500 − where: is_nil(r.repository_id) and is_nil(r.organization_id),
501 − order_by: [desc: r.last_seen_at]
492 + def list_for_scope(%Repository{id: rid}),
493 + do: from(r in Runner, where: r.repository_id == ^rid) |> or_global()
494 +
495 + def list_for_scope(%Organization{id: oid}),
496 + do: from(r in Runner, where: r.organization_id == ^oid) |> or_global()
497 +
498 + def list_for_scope(%User{id: uid}),
499 + do: from(r in Runner, where: r.owner_id == ^uid) |> or_global()
500 +
501 + def list_for_scope(:global), do: from(r in Runner, where: false) |> or_global()
502 +
503 + defp or_global(query) do
504 + from(r in query,
505 + or_where: is_nil(r.repository_id) and is_nil(r.organization_id) and is_nil(r.owner_id),
506 + order_by: [desc: r.last_seen_at]
502 507 )
508 + |> Repo.all()
503 509 end
504 510
505 511 @doc """
506 − Every runner on the instance, whatever its scope, with the repository
507 − or organization it belongs to — for the instance admins' page.
512 + Every runner on the instance, whatever its scope, with the repository,
513 + organization or user it belongs to — for the instance admins' page.
508 514 """
509 515 def list_all do
510 516 Repo.all(
511 517 from r in Runner,
512 518 order_by: [desc_nulls_last: r.last_seen_at, asc: r.name],
513 − preload: [repository: [:owner, :organization], organization: []]
519 + preload: [repository: [:owner, :organization], organization: [], owner: []]
514 520 )
515 521 end
516 522 end
modified lib/git_gud_web/components/layouts.ex
+5 −0
@@ -139,6 +139,11 @@ defmodule GitGudWeb.Layouts do
139 139 <.icon name="hero-command-line-micro" class="size-4" /> API tokens
140 140 </.link>
141 141 </li>
142 + <li>
143 + <.link navigate={~p"/users/settings/runners"} id="nav-runners">
144 + <.icon name="hero-server-stack-micro" class="size-4" /> Runners
145 + </.link>
146 + </li>
142 147 <li>
143 148 <.link navigate={~p"/users/settings/security-log"} id="nav-security-log">
144 149 <.icon name="hero-shield-check-micro" class="size-4" /> Security log
modified lib/git_gud_web/live/admin_runners_live/index.ex
+23 −12

Click to load diff…

modified lib/git_gud_web/live/repo_live/settings_runners.ex
+25 −9

Click to load diff…

added lib/git_gud_web/live/user_live/runners.ex
+432 −0

Click to load diff…

modified lib/git_gud_web/live/user_live/security_log.ex
+6 −5

Click to load diff…

modified lib/git_gud_web/router.ex
+3 −0

Click to load diff…

modified lib/mix/tasks/gitgud.runner.create.ex
+6 −0

Click to load diff…

added priv/repo/migrations/20261101231214_add_user_scoped_runners.exs
+53 −0

Click to load diff…

added test/git_gud/workflows/user_runners_test.exs
+247 −0

Click to load diff…

modified test/git_gud_web/live/settings_sudo_test.exs
+2 −1

Click to load diff…

added test/git_gud_web/live/user_runners_live_test.exs
+169 −0

Click to load diff…

Parents: 1f1d2a3