Runners have an architecture; only x86_64 ones take bare self-hosted

cd6ad80 · Gabriel Morell · 2026-10-23 19:57

28 files +399 -32
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/audit/catalog.ex
+5 −0
@@ -136,6 +136,11 @@ defmodule GitGud.Audit.Catalog do
136 136 "runner.registered" => %{category: "runners", target: "runner", keys: ~w(labels version)},
137 137 "runner.created" => %{category: "runners", target: "runner", keys: ~w(labels)},
138 138 "runner.deleted" => %{category: "runners", target: "runner", keys: ~w()},
139 + "runner.architecture_changed" => %{
140 + category: "runners",
141 + target: "runner",
142 + keys: ~w(from to)
143 + },
139 144 # Approving a fork's runs lets code nobody here wrote run on the
140 145 # repository's runners.
141 146 "ci.fork_runs_approved" => %{
modified lib/git_gud/release.ex
+46 −0
@@ -66,6 +66,52 @@ defmodule GitGud.Release do
66 66 result
67 67 end
68 68
69 + @doc """
70 + Set the architecture of the runner named `name` — the way to reach a
71 + global runner, which no settings page manages. Only an x86_64 runner
72 + takes `runs-on: self-hosted` work without listing the label.
73 +
74 + bin/git_gud eval 'GitGud.Release.set_runner_architecture("e52c", "aarch64")'
75 + """
76 + def set_runner_architecture(name, architecture)
77 + when is_binary(name) and is_binary(architecture) do
78 + load_app()
79 +
80 + {:ok, result, _} =
81 + Ecto.Migrator.with_repo(GitGud.Repo, fn repo ->
82 + import Ecto.Query, warn: false
83 +
84 + case repo.all(from r in GitGud.Workflows.Runner, where: r.name == ^name) do
85 + [runner] ->
86 + runner
87 + |> GitGud.Workflows.Runner.architecture_changeset(architecture)
88 + |> repo.update()
89 +
90 + [] ->
91 + :not_found
92 +
93 + _ ->
94 + :ambiguous
95 + end
96 + end)
97 +
98 + case result do
99 + {:ok, runner} ->
100 + IO.puts("#{runner.name}: #{runner.architecture}")
101 +
102 + {:error, cs} ->
103 + IO.puts("not changed: #{inspect(cs.errors)}")
104 +
105 + :not_found ->
106 + IO.puts("no runner named: #{name}")
107 +
108 + :ambiguous ->
109 + IO.puts("more than one runner is named #{name}; change it on its settings page")
110 + end
111 +
112 + result
113 + end
114 +
69 115 def rollback(repo, version) do
70 116 load_app()
71 117 {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
modified lib/git_gud/workflows.ex
+10 −8
@@ -1488,7 +1488,7 @@ defmodule GitGud.Workflows do
1488 1488 def claim_job_by_handle(runner, _), do: claim_next_job(runner)
1489 1489
1490 1490 defp do_claim_specific_job(%Runner{id: rid} = runner, job_id) do
1491 − match_labels = runner |> effective_labels() |> match_labels()
1491 + match_labels = match_labels(runner, effective_labels(runner))
1492 1492
1493 1493 Repo.transaction(fn ->
1494 1494 job =
@@ -1534,7 +1534,7 @@ defmodule GitGud.Workflows do
1534 1534 # `passed_over` are jobs found at their `max-parallel` once locked
1535 1535 # (`max_parallel_slot?/1`), which the next attempt looks past.
1536 1536 defp do_claim_next_job(%Runner{id: rid} = runner, labels, passed_over \\ []) do
1537 − match_labels = match_labels(labels)
1537 + match_labels = match_labels(runner, labels)
1538 1538
1539 1539 Repo.transaction(fn ->
1540 1540 job =
@@ -1650,18 +1650,20 @@ defmodule GitGud.Workflows do
1650 1650 # `runs-on: [self-hosted, arm64]` reaches an arm64 machine and only
1651 1651 # an arm64 machine.
1652 1652 #
1653 − # `self-hosted` is added implicitly: `runs-on: self-hosted` is meant
1654 − # to match any self-registered runner regardless of its other
1655 − # labels, and everything this codebase mints is by definition
1656 − # self-hosted.
1653 + # A runner's architecture is a label too (`[self-hosted, aarch64]`).
1654 + # `self-hosted` is implied for an x86_64 runner only: the workflows
1655 + # that say `runs-on: self-hosted` were written when every runner was
1656 + # x86_64, and some build images that deploy to x86_64 machines. Any
1657 + # other runner takes `self-hosted` work only if its labels list it.
1657 1658 #
1658 1659 # Built into jsonb on the SQL side from a text array rather than
1659 1660 # interpolated as a JSON string: a string parameter bound to a
1660 1661 # `::jsonb` placeholder is encoded as a JSON *string*, so containment
1661 1662 # compares an array against `"[\"a\",\"b\"]"` and quietly matches
1662 1663 # nothing. `to_jsonb(?::text[])` has one unambiguous reading.
1663 − defp match_labels(labels) do
1664 − if "self-hosted" in labels, do: labels, else: ["self-hosted" | labels]
1664 + defp match_labels(%Runner{architecture: arch}, labels) do
1665 + implied = if arch == "x86_64", do: ["self-hosted", arch], else: [arch]
1666 + Enum.uniq(implied ++ labels)
1665 1667 end
1666 1668
1667 1669 # A runner only claims jobs in its scope:
modified lib/git_gud/workflows/runner.ex
+16 −0
@@ -7,6 +7,7 @@ defmodule GitGud.Workflows.Runner do
7 7 alias GitGud.Repositories.Repository
8 8
9 9 @valid_statuses ~w(active offline disabled)
10 + @architectures ~w(x86_64 aarch64)
10 11
11 12 schema "runners" do
12 13 field :uuid, Ecto.UUID
@@ -20,6 +21,11 @@ defmodule GitGud.Workflows.Runner do
20 21 # `Declare`; we record but don't route on them. Surfaced in
21 22 # the UI for operator visibility.
22 23 field :agent_labels, :map, default: %{"labels" => []}
24 + # Operator-assigned, like `labels`. Only an x86_64 runner takes
25 + # `runs-on: self-hosted` without carrying the label: every runner
26 + # was x86_64 when that was decided, and the workflows that say
27 + # `self-hosted` build x86_64 images.
28 + field :architecture, :string, default: "x86_64"
23 29 field :token_hash, :binary
24 30 field :last_seen_at, :utc_datetime
25 31 field :status, :string, default: "active"
@@ -52,6 +58,16 @@ defmodule GitGud.Workflows.Runner do
52 58 )
53 59 end
54 60
61 + @doc "Set the runner's architecture."
62 + def architecture_changeset(runner, architecture) do
63 + runner
64 + |> change(architecture: architecture)
65 + |> validate_inclusion(:architecture, @architectures)
66 + |> check_constraint(:architecture, name: :runners_architecture)
67 + end
68 +
69 + def architectures, do: @architectures
70 +
55 71 @doc "Human label for the runner's scope."
56 72 def scope_label(%__MODULE__{repository_id: r}) when not is_nil(r), do: "repo"
57 73 def scope_label(%__MODULE__{organization_id: o}) when not is_nil(o), do: "org"
modified lib/git_gud/workflows/runners.ex
+9 −0
@@ -398,6 +398,15 @@ defmodule GitGud.Workflows.Runners do
398 398 """
399 399 def delete_runner(%Runner{} = runner), do: Repo.delete(runner)
400 400
401 + @doc """
402 + Set a runner's architecture (`Runner.architectures/0`). Only an
403 + x86_64 runner takes `runs-on: self-hosted` work without listing the
404 + label. Same authorization rule as `delete_runner/1`.
405 + """
406 + def set_architecture(%Runner{} = runner, architecture) do
407 + runner |> Runner.architecture_changeset(architecture) |> Repo.update()
408 + end
409 +
401 410 @doc """
402 411 List runners visible at `scope`.
403 412
modified lib/git_gud_web/components/audit_components.ex
+3 −0

Click to load diff…

modified lib/git_gud_web/live/org_live/settings_runners.ex
+75 −12

Click to load diff…

modified lib/git_gud_web/live/repo_live/settings_runners.ex
+75 −12

Click to load diff…

added priv/repo/migrations/20261023195053_add_runner_architecture.exs
+20 −0

Click to load diff…

modified test/git_gud/workflows/matrix_dispatch_test.exs
+85 −0

Click to load diff…

modified test/git_gud_web/live/runner_audit_test.exs
+55 −0

Click to load diff…

Parents: 990181a