corrections
8f35827 · gmorell · 2026-05-22 14:06
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 flow — only 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 | + accepted — re-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 | + > instead — it 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