corrections

8f35827 · gmorell · 2026-05-22 14:06

6 files +455 -26

Files changed

modified lib/git_gud/workflows/runners.ex
+75 −4
@@ -134,8 +134,9 @@ defmodule GitGud.Workflows.Runners do
134 134 runner's bearer credential, shown once.
135 135 """
136 136 def register(%{name: _} = attrs, scope \\ :global) do
137 token = :crypto.strong_rand_bytes(32)
138 hash = :crypto.hash(:sha256, token)
137 + raw = :crypto.strong_rand_bytes(20)
138 + hex_token = Base.encode16(raw, case: :lower)
139 + hash = :crypto.hash(:sha256, raw)
139 140 scope_attrs = scope_attrs(scope)
140 141
141 142 %Runner{token_hash: hash}
@@ -154,7 +155,7 @@ defmodule GitGud.Workflows.Runners do
154 155 )
155 156 |> Repo.insert()
156 157 |> case do
157 {:ok, runner} -> {:ok, runner, Base.url_encode64(token, padding: false)}
158 + {:ok, runner} -> {:ok, runner, hex_token}
158 159 err -> err
159 160 end
160 161 end
@@ -164,8 +165,56 @@ defmodule GitGud.Workflows.Runners do
164 165 defp scope_attrs(%Organization{id: id}), do: %{organization_id: id}
165 166 defp scope_attrs(_), do: %{}
166 167
168 + @doc """
169 + Server-side ("offline") runner mint. Creates the Runner row before
170 + the runner process ever calls us, generates a UUID + hex-encoded
171 + 20-byte token, and returns `{:ok, runner, uuid, hex_token}` for the
172 + operator to paste into `config.yml`'s
173 + `server.connections.<name>` block:
174 +
175 + server:
176 + connections:
177 + gitgud:
178 + url: https://gg.neiam.co/
179 + uuid: <uuid>
180 + token: <hex_token>
181 +
182 + Storage uses sha256 of the decoded token bytes so the schema layout
183 + is identical to the legacy online flowonly the wire encoding
184 + differs (hex vs base64url).
185 + """
186 + def create_offline(attrs, scope \\ :global) when is_map(attrs) do
187 + raw = :crypto.strong_rand_bytes(20)
188 + hex_token = Base.encode16(raw, case: :lower)
189 + hash = :crypto.hash(:sha256, raw)
190 + scope_attrs = scope_attrs(scope)
191 +
192 + %Runner{token_hash: hash, uuid: Ecto.UUID.generate()}
193 + |> Runner.changeset(
194 + Map.merge(
195 + %{
196 + name: Map.fetch!(attrs, :name),
197 + labels: Map.get(attrs, :labels, %{"labels" => ["ubuntu-latest"]})
198 + },
199 + scope_attrs
200 + )
201 + )
202 + |> Repo.insert()
203 + |> case do
204 + {:ok, runner} -> {:ok, runner, runner.uuid, hex_token}
205 + err -> err
206 + end
207 + end
208 +
209 + @doc """
210 + Hex-token lookup. Modern forgejo-runner (v6+) sends the bare hex
211 + token in `x-runner-token`; we decode, sha256, and match against
212 + `token_hash`. Legacy base64url-encoded tokens are no longer
213 + acceptedre-mint any runners still on the old flow with
214 + `create_offline/2`.
215 + """
167 216 def find_by_token(token) when is_binary(token) do
168 with {:ok, raw} <- Base.url_decode64(token, padding: false) do
217 + with {:ok, raw} <- Base.decode16(token, case: :mixed) do
169 218 hash = :crypto.hash(:sha256, raw)
170 219 Repo.get_by(Runner, token_hash: hash)
171 220 else
@@ -175,6 +224,28 @@ defmodule GitGud.Workflows.Runners do
175 224
176 225 def find_by_token(_), do: nil
177 226
227 + @doc """
228 + Validate a runner by both `(uuid, hex_token)`. Faster than
229 + `find_by_token/1` when the caller already has the UUID (lookup hits
230 + a unique index instead of the token-hash index), and adds a
231 + constant-time identity check on top of the hash match.
232 + """
233 + def find_by_uuid_and_token(uuid, hex_token)
234 + when is_binary(uuid) and is_binary(hex_token) do
235 + with {:ok, raw} <- Base.decode16(hex_token, case: :mixed),
236 + %Runner{} = runner <- Repo.get_by(Runner, uuid: uuid),
237 + expected_hash <- runner.token_hash,
238 + hash <- :crypto.hash(:sha256, raw),
239 + true <- byte_size(expected_hash) == byte_size(hash),
240 + true <- Plug.Crypto.secure_compare(expected_hash, hash) do
241 + runner
242 + else
243 + _ -> nil
244 + end
245 + end
246 +
247 + def find_by_uuid_and_token(_, _), do: nil
248 +
178 249 def touch_last_seen(%Runner{} = runner) do
179 250 runner
180 251 |> Ecto.Changeset.change(last_seen_at: DateTime.utc_now(:second))
modified lib/git_gud_web/components/core_components.ex
+7 −0
@@ -593,6 +593,13 @@ defmodule GitGudWeb.CoreComponents do
593 593 snippets for native binary, Docker, Podman, Podman Quadlet, and
594 594 Kubernetes.
595 595
596 + > **Deprecated for forgejo-runner v6+ (`data.forgejo.org/forgejo/runner:12+`).**
597 + > Modern runners read credentials from `config.yml` directly under
598 + > `server.connections.<name>` and never call `RunnerService/Register`.
599 + > Surface the offline-mint LiveView form (`Runners.create_offline/2`)
600 + > insteadit returns a UUID + hex token ready to paste.
601 + > This component remains for **legacy (v5)** runner deployments.
602 +
596 603 Use on runner settings pages right after a new token is minted
597 604 pass the bare token + the instance URL.
598 605
modified lib/git_gud_web/controllers/runner_controller.ex
+35 −18
@@ -332,25 +332,42 @@ defmodule GitGudWeb.RunnerController do
332 332 # ── helpers ──────────────────────────────────────────────────────────
333 333
334 334 defp authenticate(conn) do
335 # forgejo-runner / act_runner send their post-Register credential
336 # via the `x-runner-token` header on every Twirp call. Some forks
337 # (and our own `forgejo-runner` interceptor in newer builds) also
338 # accept `Authorization: Bearer <token>`. Try both so we work with
339 # whatever version of runner is registering.
340 token =
341 case Plug.Conn.get_req_header(conn, "x-runner-token") do
342 [t | _] when t != "" ->
343 t
344
345 _ ->
346 case Plug.Conn.get_req_header(conn, "authorization") do
347 ["Bearer " <> t] -> t
348 _ -> nil
349 end
350 end
335 + # Modern forgejo-runner (v6+ / `runner:12`) sends both
336 + # `x-runner-uuid` and `x-runner-token` on every Twirp call (see
337 + # forgejo/runner internal/pkg/client/header.go). When the UUID is
338 + # present we prefer it — single-row lookup on the unique uuid
339 + # index, plus a constant-time identity check.
340 + uuid = first_header(conn, "x-runner-uuid")
341 + token = first_header(conn, "x-runner-token") || bearer_token(conn)
342 +
343 + cond do
344 + uuid && token ->
345 + case Runners.find_by_uuid_and_token(uuid, token) do
346 + %RunnerSchema{} = runner -> runner
347 + _ -> nil
348 + end
349 +
350 + token ->
351 + case Runners.find_by_token(token) do
352 + %RunnerSchema{} = runner -> runner
353 + _ -> nil
354 + end
355 +
356 + true ->
357 + nil
358 + end
359 + end
360 +
361 + defp first_header(conn, name) do
362 + case Plug.Conn.get_req_header(conn, name) do
363 + [v | _] when v != "" -> v
364 + _ -> nil
365 + end
366 + end
351 367
352 case token && Runners.find_by_token(token) do
353 %RunnerSchema{} = runner -> runner
368 + defp bearer_token(conn) do
369 + case Plug.Conn.get_req_header(conn, "authorization") do
370 + ["Bearer " <> t] when t != "" -> t
354 371 _ -> nil
355 372 end
356 373 end
modified lib/git_gud_web/live/org_live/settings_runners.ex
+111 −2
@@ -37,6 +37,7 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
37 37 |> assign(:org, org)
38 38 |> assign(:page_title, "Runners — #{org.handle}")
39 39 |> assign(:new_token, nil)
40 + |> assign(:new_runner, nil)
40 41 |> load_runners()}
41 42 end
42 43 end
@@ -61,6 +62,37 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
61 62 end
62 63 end
63 64
65 + def handle_event("create_runner", params, socket) do
66 + name = params["name"] |> to_string() |> String.trim()
67 + labels = parse_labels(params["labels"])
68 +
69 + cond do
70 + name == "" ->
71 + {:noreply, put_flash(socket, :error, "Runner name is required.")}
72 +
73 + true ->
74 + case Runners.create_offline(
75 + %{name: name, labels: %{"labels" => labels}},
76 + socket.assigns.org
77 + ) do
78 + {:ok, runner, uuid, hex_token} ->
79 + {:noreply,
80 + socket
81 + |> assign(:new_runner, %{
82 + name: runner.name,
83 + uuid: uuid,
84 + token: hex_token,
85 + url: GitGudWeb.Endpoint.url()
86 + })
87 + |> load_runners()
88 + |> put_flash(:info, "Runner '#{runner.name}' minted. Save the token — it's only shown once.")}
89 +
90 + {:error, cs} ->
91 + {:noreply, put_flash(socket, :error, "Could not create runner: #{inspect(cs.errors)}")}
92 + end
93 + end
94 + end
95 +
64 96 def handle_event("delete_runner", %{"id" => id}, socket) do
65 97 runner = GitGud.Repo.get!(GitGud.Workflows.Runner, String.to_integer(id))
66 98
@@ -83,13 +115,65 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
83 115 from every repository the org owns.
84 116 </p>
85 117
118 + <section class="border border-base-300 rounded p-4 space-y-3">
119 + <header class="space-y-1">
120 + <h2 class="font-semibold">Create runner (offline)</h2>
121 + <p class="text-xs opacity-70">
122 + Modern <code>forgejo-runner</code> (v6+) reads its credentials from
123 + <code>config.yml</code> directly. Pick a name + labels, then paste the
124 + <code>server.connections</code> block below into your runner's
125 + <code>config.yml</code> and start the daemon.
126 + </p>
127 + </header>
128 +
129 + <form phx-submit="create_runner" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end">
130 + <label class="text-xs space-y-1">
131 + <span class="opacity-70">Name</span>
132 + <input
133 + type="text"
134 + name="name"
135 + required
136 + placeholder="builder-1"
137 + class="input input-sm input-bordered w-full font-mono"
138 + />
139 + </label>
140 + <label class="text-xs space-y-1">
141 + <span class="opacity-70">Labels (comma-separated)</span>
142 + <input
143 + type="text"
144 + name="labels"
145 + value="ubuntu-latest"
146 + class="input input-sm input-bordered w-full font-mono"
147 + />
148 + </label>
149 + <button type="submit" class="btn btn-sm btn-primary">Create</button>
150 + </form>
151 +
152 + <%= if @new_runner do %>
153 + <div class="text-sm space-y-2 mt-3">
154 + <p class="text-warning">Save this token now — it's only shown once.</p>
155 + <pre
156 + class="text-xs bg-base-200 p-3 rounded overflow-x-auto"
157 + phx-no-curly-interpolation
158 + ><%= runner_connections_yaml(@new_runner) %></pre>
159 + <p class="text-xs opacity-70">
160 + Then run: <code>forgejo-runner daemon --config config.yml</code>
161 + </p>
162 + </div>
163 + <% end %>
164 + </section>
165 +
86 166 <section class="border border-base-300 rounded p-4 space-y-3">
87 167 <header class="flex items-baseline justify-between">
88 <h2 class="font-semibold">Registration token</h2>
89 <button type="button" phx-click="mint_token" class="btn btn-sm btn-primary">
168 + <h2 class="font-semibold">Legacy registration token</h2>
169 + <button type="button" phx-click="mint_token" class="btn btn-sm btn-ghost">
90 170 {if Runners.has_token?(@org), do: "Regenerate", else: "Generate"}
91 171 </button>
92 172 </header>
173 + <p class="text-xs opacity-60">
174 + Pre-v6 <code>forgejo-runner register --instance --token</code> flow.
175 + Prefer the offline mint above for new deployments.
176 + </p>
93 177
94 178 <%= cond do %>
95 179 <% @new_token -> %>
@@ -159,6 +243,31 @@ defmodule GitGudWeb.OrgLive.SettingsRunners do
159 243 defp scope_class(%{organization_id: o}) when not is_nil(o), do: "badge-secondary"
160 244 defp scope_class(_), do: "badge-ghost"
161 245
246 + defp parse_labels(nil), do: ["ubuntu-latest"]
247 +
248 + defp parse_labels(str) when is_binary(str) do
249 + str
250 + |> String.split(",", trim: true)
251 + |> Enum.map(&String.trim/1)
252 + |> Enum.reject(&(&1 == ""))
253 + |> case do
254 + [] -> ["ubuntu-latest"]
255 + list -> list
256 + end
257 + end
258 +
259 + defp runner_connections_yaml(%{name: _name, uuid: uuid, token: token, url: url}) do
260 + """
261 + server:
262 + connections:
263 + gitgud:
264 + url: #{url}/
265 + uuid: #{uuid}
266 + token: #{token}
267 + """
268 + |> String.trim_trailing()
269 + end
270 +
162 271 defp labels_label(%{labels: %{"labels" => ls}}) when is_list(ls), do: Enum.join(ls, ", ")
163 272 defp labels_label(_), do: "—"
164 273
modified lib/git_gud_web/live/repo_live/settings_runners.ex
+111 −2
@@ -48,6 +48,7 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
48 48 |> GitGudWeb.RepoLive.Header.assign_chrome(repo)
49 49 |> assign(:page_title, "Runners — #{owner}/#{name}")
50 50 |> assign(:new_token, nil)
51 + |> assign(:new_runner, nil)
51 52 |> load_runners()}
52 53 end
53 54 end
@@ -84,6 +85,37 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
84 85 end
85 86 end
86 87
88 + def handle_event("create_runner", params, socket) do
89 + name = params["name"] |> to_string() |> String.trim()
90 + labels = parse_labels(params["labels"])
91 +
92 + cond do
93 + name == "" ->
94 + {:noreply, put_flash(socket, :error, "Runner name is required.")}
95 +
96 + true ->
97 + case Runners.create_offline(
98 + %{name: name, labels: %{"labels" => labels}},
99 + socket.assigns.repo
100 + ) do
101 + {:ok, runner, uuid, hex_token} ->
102 + {:noreply,
103 + socket
104 + |> assign(:new_runner, %{
105 + name: runner.name,
106 + uuid: uuid,
107 + token: hex_token,
108 + url: GitGudWeb.Endpoint.url()
109 + })
110 + |> load_runners()
111 + |> put_flash(:info, "Runner '#{runner.name}' minted. Save the token — shown once.")}
112 +
113 + {:error, cs} ->
114 + {:noreply, put_flash(socket, :error, "Could not create runner: #{inspect(cs.errors)}")}
115 + end
116 + end
117 + end
118 +
87 119 def handle_event("delete_runner", %{"id" => id}, socket) do
88 120 runner = GitGud.Repo.get!(GitGud.Workflows.Runner, String.to_integer(id))
89 121
@@ -114,13 +146,65 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
114 146 the token below only picks up jobs from this repository.
115 147 </p>
116 148
149 + <section class="border border-base-300 rounded p-4 space-y-3 max-w-3xl">
150 + <header class="space-y-1">
151 + <h2 class="font-semibold">Create runner (offline)</h2>
152 + <p class="text-xs opacity-70">
153 + Modern <code>forgejo-runner</code> (v6+) reads its credentials from
154 + <code>config.yml</code> directly. Pick a name + labels, then paste the
155 + <code>server.connections</code> block below into your runner's
156 + <code>config.yml</code> and start the daemon.
157 + </p>
158 + </header>
159 +
160 + <form phx-submit="create_runner" class="grid grid-cols-1 md:grid-cols-[1fr_1fr_auto] gap-2 items-end">
161 + <label class="text-xs space-y-1">
162 + <span class="opacity-70">Name</span>
163 + <input
164 + type="text"
165 + name="name"
166 + required
167 + placeholder={@repo.name <> "-runner"}
168 + class="input input-sm input-bordered w-full font-mono"
169 + />
170 + </label>
171 + <label class="text-xs space-y-1">
172 + <span class="opacity-70">Labels (comma-separated)</span>
173 + <input
174 + type="text"
175 + name="labels"
176 + value="ubuntu-latest"
177 + class="input input-sm input-bordered w-full font-mono"
178 + />
179 + </label>
180 + <button type="submit" class="btn btn-sm btn-primary">Create</button>
181 + </form>
182 +
183 + <%= if @new_runner do %>
184 + <div class="text-sm space-y-2 mt-3">
185 + <p class="text-warning">Save this token now — it's only shown once.</p>
186 + <pre
187 + class="text-xs bg-base-200 p-3 rounded overflow-x-auto"
188 + phx-no-curly-interpolation
189 + ><%= runner_connections_yaml(@new_runner) %></pre>
190 + <p class="text-xs opacity-70">
191 + Then run: <code>forgejo-runner daemon --config config.yml</code>
192 + </p>
193 + </div>
194 + <% end %>
195 + </section>
196 +
117 197 <section class="border border-base-300 rounded p-4 space-y-3 max-w-3xl">
118 198 <header class="flex items-baseline justify-between">
119 <h2 class="font-semibold">Registration token</h2>
120 <button type="button" phx-click="mint_token" class="btn btn-sm btn-primary">
199 + <h2 class="font-semibold">Legacy registration token</h2>
200 + <button type="button" phx-click="mint_token" class="btn btn-sm btn-ghost">
121 201 {if Runners.has_token?(@repo), do: "Regenerate", else: "Generate"}
122 202 </button>
123 203 </header>
204 + <p class="text-xs opacity-60">
205 + Pre-v6 <code>forgejo-runner register --instance --token</code> flow.
206 + Prefer the offline mint above for new deployments.
207 + </p>
124 208
125 209 <%= cond do %>
126 210 <% @new_token -> %>
@@ -199,6 +283,31 @@ defmodule GitGudWeb.RepoLive.SettingsRunners do
199 283 defp scope_class(%{organization_id: o}) when not is_nil(o), do: "badge-secondary"
200 284 defp scope_class(_), do: "badge-ghost"
201 285
286 + defp parse_labels(nil), do: ["ubuntu-latest"]
287 +
288 + defp parse_labels(str) when is_binary(str) do
289 + str
290 + |> String.split(",", trim: true)
291 + |> Enum.map(&String.trim/1)
292 + |> Enum.reject(&(&1 == ""))
293 + |> case do
294 + [] -> ["ubuntu-latest"]
295 + list -> list
296 + end
297 + end
298 +
299 + defp runner_connections_yaml(%{name: _name, uuid: uuid, token: token, url: url}) do
300 + """
301 + server:
302 + connections:
303 + gitgud:
304 + url: #{url}/
305 + uuid: #{uuid}
306 + token: #{token}
307 + """
308 + |> String.trim_trailing()
309 + end
310 +
202 311 defp labels_label(%{labels: %{"labels" => ls}}) when is_list(ls), do: Enum.join(ls, ", ")
203 312 defp labels_label(_), do: "—"
204 313
added lib/mix/tasks/gitgud.runner.create.ex
+116 −0
@@ -0,0 +1,116 @@
1 +defmodule Mix.Tasks.Gitgud.Runner.Create do
2 + @shortdoc "Offline-mint a runner: prints the server.connections snippet for config.yml."
3 +
4 + @moduledoc """
5 + mix gitgud.runner.create --scope SCOPE --name NAME [--labels A,B,C]
6 +
7 + Modern (v6+) `forgejo-runner` no longer calls `RunnerService/Register`;
8 + the operator mints the runner server-side and pastes a YAML snippet
9 + into `config.yml`'s `server.connections.<name>` block. This task is
10 + the server-side mint.
11 +
12 + `SCOPE`:
13 + * `global` — admin-only, picks up jobs from anywhere
14 + * `<org_handle>` — org-scoped
15 + * `<owner>/<repo>` — repo-scoped
16 +
17 + `LABELS` is a comma-separated list (default: `ubuntu-latest`).
18 +
19 + ## Examples
20 +
21 + mix gitgud.runner.create --scope acme --name builder-1
22 + mix gitgud.runner.create --scope acme/web --name fast --labels ubuntu-latest,docker
23 + mix gitgud.runner.create --scope global --name house
24 +
25 + Prints the YAML snippet ready to paste under `server.connections.gitgud`
26 + in the runner's `config.yml`. The hex token is shown once — record it.
27 + """
28 +
29 + use Mix.Task
30 +
31 + @switches [scope: :string, name: :string, labels: :string]
32 +
33 + @impl Mix.Task
34 + def run(argv) do
35 + {opts, _, _} = OptionParser.parse(argv, switches: @switches)
36 + Mix.Task.run("app.start")
37 +
38 + name = opts[:name] || Mix.raise("--name is required")
39 + scope_str = opts[:scope] || Mix.raise("--scope is required")
40 +
41 + labels =
42 + (opts[:labels] || "ubuntu-latest")
43 + |> String.split(",", trim: true)
44 + |> Enum.map(&String.trim/1)
45 + |> Enum.reject(&(&1 == ""))
46 + |> case do
47 + [] -> ["ubuntu-latest"]
48 + list -> list
49 + end
50 +
51 + scope = resolve_scope(scope_str)
52 +
53 + case GitGud.Workflows.Runners.create_offline(
54 + %{name: name, labels: %{"labels" => labels}},
55 + scope
56 + ) do
57 + {:ok, _runner, uuid, hex_token} ->
58 + url = GitGudWeb.Endpoint.url()
59 +
60 + Mix.shell().info("""
61 +
62 + ── Runner minted ──────────────────────────────────────────────
63 +
64 + Paste this into your runner's `config.yml` under `server.connections`:
65 +
66 + server:
67 + connections:
68 + gitgud:
69 + url: #{url}/
70 + uuid: #{uuid}
71 + token: #{hex_token}
72 +
73 + Then start the daemon:
74 +
75 + forgejo-runner daemon --config config.yml
76 +
77 + Notes
78 + * The token is shown once. If you lose it, mint a new runner
79 + and delete this one from the settings UI.
80 + * The runner identifies itself with the UUID above; rotating
81 + tokens means re-minting (the server has no way to update
82 + an existing runner's secret).
83 + """)
84 +
85 + {:error, cs} ->
86 + Mix.shell().error("""
87 +
88 + Failed to mint runner: #{inspect(cs.errors)}
89 + """)
90 +
91 + System.halt(1)
92 + end
93 + end
94 +
95 + defp resolve_scope("global"), do: :global
96 +
97 + defp resolve_scope(scope) do
98 + case String.split(scope, "/", parts: 2) do
99 + [owner, name] ->
100 + try do
101 + GitGud.Repositories.get_repository_by_path!(owner, name)
102 + rescue
103 + Ecto.NoResultsError ->
104 + Mix.raise("repo not found: #{scope}")
105 + end
106 +
107 + [handle] ->
108 + try do
109 + GitGud.Organizations.get_organization_by_handle!(handle)
110 + rescue
111 + Ecto.NoResultsError ->
112 + Mix.raise("org not found: #{handle}")
113 + end
114 + end
115 + end
116 +end

Parents: 47f23e8