Homepage: current features, and a Run your own guide

a81156c · Gabriel Morell · 2026-10-20 20:54

20 files +498 -7
Message
{commit_body(@commit)}

Files changed

modified README.md
+1 −0
@@ -725,6 +725,7 @@ kubectl -n git-gud exec deployment/git-gud -- \
725 725 |---|---|
726 726 | `migrate()` | Run pending migrations. The one you need on every deploy that ships one |
727 727 | `rollback(GitGud.Repo, version)` | Roll a migration back |
728 +| `promote_admin("you@example.com")` | Make that account an instance admin — how a fresh install gets its first one (`mix gitgud.promote_admin` from a checkout). The step-by-step for a new instance, container or `.deb`, is at `/run-your-own` |
728 729 | `keygen()` | Mint the RS256 keypair for the OCI token endpoint, printing both PEMs with `===PRIV===` / `===PUB===` delimiters so they can be piped into `kubectl create secret` without touching a laptop |
729 730 | `configure_repos()` | Backfill the per-repo git config `actions/checkout` needs (`uploadpack.allow*SHA1InWant`). New repos get it at creation; this fixes older ones |
730 731 | `backfill_language_tags()` | Derive each repo's language tag from the stats already computed for it. New stats tag their repo as they're written, so this is for repos whose stats predate the tag |
modified config/runtime.exs
+5 −2
@@ -357,7 +357,7 @@ if config_env() == :prod do
357 357 # * otherwise → Swoosh.Adapters.Logger
358 358 #
359 359 # The Logger adapter doesn't actually send mail — it logs the
360 − # rendered email at :info — but it gets us off Swoosh.Adapters.Local
360 + # whole email at :info — but it gets us off Swoosh.Adapters.Local
361 361 # (which is what the dev mailbox uses), so the magic-link flow at
362 362 # least leaves a trail in the pod log.
363 363 # Literal branches (not `String.to_existing_atom/1`) so the atom
@@ -473,7 +473,10 @@ if config_env() == :prod do
473 473 no_mx_lookups: false
474 474 ]
475 475 else
476 − [adapter: Swoosh.Adapters.Logger, level: :info]
476 + # The whole email, not only its recipient: without a relay the
477 + # log is the only way a sign-in link reaches anyone, which also
478 + # means whoever reads the log can use those links.
479 + [adapter: Swoosh.Adapters.Logger, level: :info, log_full_email: true]
477 480 end
478 481
479 482 config :git_gud, GitGud.Mailer, mailer_opts
modified lib/git_gud/release.ex
+29 −0
@@ -37,6 +37,35 @@ defmodule GitGud.Release do
37 37 counts
38 38 end
39 39
40 + @doc """
41 + Make the account with `email` an instance admin — how a fresh install
42 + gets its first one. The release twin of `mix gitgud.promote_admin`.
43 +
44 + bin/git_gud eval 'GitGud.Release.promote_admin("you@example.com")'
45 + """
46 + def promote_admin(email) when is_binary(email) do
47 + load_app()
48 +
49 + {:ok, result, _} =
50 + Ecto.Migrator.with_repo(GitGud.Repo, fn repo ->
51 + case repo.get_by(GitGud.Accounts.User, email: String.trim(email)) do
52 + nil ->
53 + :not_found
54 +
55 + user ->
56 + user |> GitGud.Accounts.User.admin_changeset(true) |> repo.update!()
57 + :ok
58 + end
59 + end)
60 +
61 + case result do
62 + :ok -> IO.puts("promoted: #{email}")
63 + :not_found -> IO.puts("no user with email: #{email}")
64 + end
65 +
66 + result
67 + end
68 +
40 69 def rollback(repo, version) do
41 70 load_app()
42 71 {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
modified lib/git_gud_web/components/landing_components.ex
+38 −5
@@ -50,12 +50,17 @@ defmodule GitGudWeb.LandingComponents do
50 50 <.feature
51 51 icon="hero-arrow-trending-up"
52 52 title="Forks, PRs, line-level diffs"
53 − body="3-way merge, fork sync, compare any two refs. Diffs render hunk-by-hunk with syntax-highlighted lines; large diffs keep their hunks out of the DOM until you open the file."
53 + body="3-way merge, fork sync, compare any two refs, and conflicts resolved in the browser. Diffs render hunk-by-hunk with syntax-highlighted lines; large diffs keep their hunks out of the DOM until you open the file."
54 54 />
55 55 <.feature
56 56 icon="hero-bolt"
57 57 title="Forgejo Actions CI"
58 − body="Talk protobuf to forgejo-runner. Per-task JWTs, encrypted secrets, artifacts + cache with hit matching, re-run + cancel from the UI."
58 + body="Point any forgejo-runner at it. Matrices across architectures, needs and outputs, concurrency groups, and every trigger you'd reach for — push, pull_request(_target), dispatch with inputs, schedule, workflow_run. A commit status per job, required checks in branch protection, re-run and cancel that actually stop the runner."
59 + />
60 + <.feature
61 + icon="hero-key"
62 + title="Job tokens that know their place"
63 + body="Each job gets a token that works only while it runs, only on its own repository, and only as far as its permissions: say. Fork pull requests wait for a maintainer's approval and run with no secrets and a read-only token."
59 64 />
60 65 <.feature
61 66 icon="hero-globe-alt"
@@ -95,7 +100,12 @@ defmodule GitGudWeb.LandingComponents do
95 100 <.feature
96 101 icon="hero-finger-print"
97 102 title="MFA + WebAuthn"
98 − body="TOTP authenticator apps, FIDO2 security keys (YubiKey, passkeys, Touch ID), recovery codes. Optional instance-wide enforcement with a grace window."
103 + body="TOTP authenticator apps, FIDO2 security keys (YubiKey, passkeys, Touch ID), recovery codes. Sudo mode in front of every credential change, and optional instance-wide enforcement with a grace window."
104 + />
105 + <.feature
106 + icon="hero-lock-closed"
107 + title="Hardened by default"
108 + body="Secrets encrypted under a key it refuses to boot without. Wrong passwords, sign-in emails and sign-ups rate limited. Outbound requests refuse private addresses. Uploaded bytes never render on the forge's own origin."
99 109 />
100 110 <.feature
101 111 icon="hero-paint-brush"
@@ -109,8 +119,23 @@ defmodule GitGudWeb.LandingComponents do
109 119 />
110 120 <.feature
111 121 icon="hero-book-open"
112 − title="Wiki + container registry"
113 − body="Sister .wiki.git you edit through the UI. Container packages mirrored from zot via webhook, browsable per-repo without leaving the forge."
122 + title="Wikis"
123 + body="Every repository gets a sister .wiki.git, edited through the UI or cloned and pushed like any repo."
124 + />
125 + <.feature
126 + icon="hero-cube"
127 + title="Package registries"
128 + body="Containers, PyPI, Hex, Cargo, apt and raw files, served by the forge. CI publishes with its job token — no stored secret — and one signing key per org covers everything it ships."
129 + />
130 + <.feature
131 + icon="hero-arrow-down-on-square-stack"
132 + title="Bring your repositories"
133 + body="Import a GitHub org or user, a GitLab group, or any git URL, each on its own background job with live progress. Keep them in sync on demand or hourly, mirrored or reconciled."
134 + />
135 + <.feature
136 + icon="hero-device-phone-mobile"
137 + title="Android app + gg CLI"
138 + body="CI runs, logs and re-runs, workflow dispatch, fork-run approval, issues and pull requests from your phone or your terminal. Pair a phone by scanning a QR code; the device is its token."
114 139 />
115 140 <.feature
116 141 icon="hero-user-circle"
@@ -135,6 +160,12 @@ defmodule GitGudWeb.LandingComponents do
135 160 label="All visible repositories"
136 161 navigate={~p"/repositories"}
137 162 />
163 + <.quick_link
164 + id="quick-run-your-own"
165 + icon="hero-server-stack"
166 + label="Run your own"
167 + navigate={~p"/run-your-own"}
168 + />
138 169 </section>
139 170 """
140 171 end
@@ -216,10 +247,12 @@ defmodule GitGudWeb.LandingComponents do
216 247 attr :icon, :string, required: true
217 248 attr :label, :string, required: true
218 249 attr :navigate, :string, required: true
250 + attr :id, :string, default: nil
219 251
220 252 def quick_link(assigns) do
221 253 ~H"""
222 254 <.link
255 + id={@id}
223 256 navigate={@navigate}
224 257 class="flex items-center justify-between rounded-box px-4 py-3 bg-base-200 hover:bg-base-300"
225 258 >
added lib/git_gud_web/live/page_live/run_your_own.ex
+329 −0
@@ -0,0 +1,329 @@
1 +defmodule GitGudWeb.PageLive.RunYourOwn do
2 + @moduledoc """
3 + `/run-your-own/:method` — how to stand up a forge of your own, as
4 + commands to paste: a container (Docker or Podman) or the Debian
5 + package. Both are what this forge's own CI publishes.
6 +
7 + The host, admin email and database password typed at the top are
8 + filled into every command as you type. The secrets are generated by
9 + `openssl` on the reader's machine, inside the commands, never here.
10 + """
11 + use GitGudWeb, :live_view
12 +
13 + import GitGudWeb.LandingComponents, only: [site_footer: 1]
14 +
15 + # {slug, label}
16 + @methods [{"podman", "Podman"}, {"docker", "Docker"}, {"debian", "Debian package"}]
17 +
18 + @image "ggr.neiam.co/neiam/gitgud:latest"
19 + @apt_host "gg.neiam.co"
20 + @apt_owner "neiam"
21 + @source_url "https://gg.neiam.co/r/neiam/gitgud"
22 +
23 + @defaults %{"host" => "", "email" => "", "db_pass" => ""}
24 +
25 + @impl true
26 + def mount(_params, _session, socket) do
27 + {:ok,
28 + socket
29 + |> assign(:page_title, "Run your own")
30 + |> assign(:methods, @methods)
31 + |> assign(:image, @image)
32 + |> assign(:source_url, @source_url)
33 + |> assign(:config_form, to_form(@defaults, as: :config))}
34 + end
35 +
36 + @impl true
37 + def handle_params(params, _uri, socket) do
38 + method =
39 + if params["method"] in Enum.map(@methods, &elem(&1, 0)),
40 + do: params["method"],
41 + else: "podman"
42 +
43 + {:noreply, socket |> assign(:method, method) |> assign_steps()}
44 + end
45 +
46 + @impl true
47 + def handle_event("configure", %{"config" => params}, socket) do
48 + form = to_form(Map.merge(@defaults, Map.take(params, Map.keys(@defaults))), as: :config)
49 + {:noreply, socket |> assign(:config_form, form) |> assign_steps()}
50 + end
51 +
52 + defp assign_steps(socket) do
53 + %{method: method, config_form: form} = socket.assigns
54 +
55 + values = %{
56 + host: value(form, :host, "git.example.com"),
57 + email: value(form, :email, "you@example.com"),
58 + db_pass: value(form, :db_pass, "change-me")
59 + }
60 +
61 + assign(socket, :steps, steps(method, values))
62 + end
63 +
64 + defp value(form, key, default) do
65 + case form[key].value |> to_string() |> String.trim() do
66 + "" -> default
67 + v -> v
68 + end
69 + end
70 +
71 + # ── the commands ─────────────────────────────────────────────────────
72 +
73 + # Each step: {id, title, note, command}.
74 + defp steps("debian", v) do
75 + [
76 + {"apt", "Add the apt repository and install",
77 + "The package bundles its own Erlang runtime. It installs enabled but not started.",
78 + """
79 + curl -fsSL https://#{@apt_host}/pkg/_keys/deb/#{@apt_owner} \\
80 + | sudo tee /etc/apt/keyrings/#{@apt_owner}.asc > /dev/null
81 + echo "deb [signed-by=/etc/apt/keyrings/#{@apt_owner}.asc] https://#{@apt_host}/pkg/deb/#{@apt_owner}/gitgud $(lsb_release -cs) main" \\
82 + | sudo tee /etc/apt/sources.list.d/gitgud.list
83 + sudo apt update && sudo apt install gitgud postgresql\
84 + """},
85 + {"db", "Create the database", nil,
86 + """
87 + sudo -u postgres psql \\
88 + -c "CREATE USER gitgud PASSWORD '#{v.db_pass}'" \\
89 + -c "CREATE DATABASE gitgud OWNER gitgud"\
90 + """},
91 + {"config", "Write the configuration",
92 + "Run this once: it generates the secrets. Keep a copy of the file somewhere safe — " <>
93 + "GITGUD_SECRETS_MASTER_KEY encrypts CI secrets and two-factor keys, and without it they're gone.",
94 + """
95 + sudo tee /etc/gitgud/gitgud.env > /dev/null <<EOF
96 + #{env_lines(v, db_host: "localhost", data: "/var/lib/gitgud")}
97 + EOF\
98 + """},
99 + {"start", "Migrate and start", nil,
100 + """
101 + sudo -u gitgud sh -c 'set -a; . /etc/gitgud/gitgud.env; exec /opt/gitgud/bin/migrate'
102 + sudo systemctl start gitgud\
103 + """}
104 + ] ++
105 + common_steps(
106 + v,
107 + "sudo -u gitgud sh -c 'set -a; . /etc/gitgud/gitgud.env; exec /opt/gitgud/bin/git_gud eval \"GitGud.Release.promote_admin(\\\"#{v.email}\\\")\"'",
108 + "sudo journalctl -u gitgud",
109 + "sudo sed -i '/^REGISTRATION_MODE=/d' /etc/gitgud/gitgud.env && sudo systemctl restart gitgud"
110 + )
111 + end
112 +
113 + defp steps(bin, v) when bin in ["podman", "docker"] do
114 + run = "#{bin} run -d --restart unless-stopped --network gitgud"
115 +
116 + [
117 + {"db", "Start a database",
118 + "Postgres on a private network the forge joins; nothing is published to the host.",
119 + """
120 + #{bin} network create gitgud
121 + #{run} --name gitgud-db \\
122 + -e POSTGRES_USER=gitgud -e POSTGRES_PASSWORD=#{v.db_pass} -e POSTGRES_DB=gitgud \\
123 + -v gitgud-db:/var/lib/postgresql/data \\
124 + docker.io/library/postgres:16-alpine\
125 + """},
126 + {"config", "Write the configuration",
127 + "Run this once: it generates the secrets. Keep a copy of gitgud.env somewhere safe — " <>
128 + "GITGUD_SECRETS_MASTER_KEY encrypts CI secrets and two-factor keys, and without it they're gone.",
129 + """
130 + (umask 077; cat > gitgud.env <<EOF
131 + #{env_lines(v, db_host: "gitgud-db", data: "/data")}
132 + EOF
133 + )\
134 + """},
135 + {"start", "Migrate and start",
136 + "The image runs as nobody, so the data volume is handed to it first. " <>
137 + "Git over SSH is on port 2222; the web side stays on localhost for the proxy below.",
138 + """
139 + #{bin} run --rm --user root -v gitgud-data:/data #{@image} chown nobody /data
140 + #{bin} run --rm --network gitgud --env-file gitgud.env -v gitgud-data:/data #{@image} /app/bin/migrate
141 + #{run} --name gitgud --env-file gitgud.env -v gitgud-data:/data \\
142 + -p 127.0.0.1:4000:4000 -p 2222:2222 \\
143 + #{@image}\
144 + """}
145 + ] ++
146 + common_steps(
147 + v,
148 + "#{bin} exec gitgud /app/bin/git_gud eval 'GitGud.Release.promote_admin(\"#{v.email}\")'",
149 + "#{bin} logs gitgud",
150 + "sed -i '/^REGISTRATION_MODE=/d' gitgud.env && #{bin} rm -f gitgud && \\\n " <>
151 + "#{run} --name gitgud --env-file gitgud.env -v gitgud-data:/data \\\n " <>
152 + "-p 127.0.0.1:4000:4000 -p 2222:2222 #{@image}"
153 + )
154 + end
155 +
156 + defp common_steps(v, promote, logs, close) do
157 + [
158 + {"tls", "Put HTTPS in front",
159 + "The forge serves plain HTTP on port 4000 and expects to be reached as https://#{v.host}. " <>
160 + "Any reverse proxy works; Caddy gets the certificate itself.",
161 + "caddy reverse-proxy --from #{v.host} --to 127.0.0.1:4000"},
162 + {"admin", "Make yourself the admin",
163 + "Register at https://#{v.host}/users/register as #{v.email}. With no SMTP_RELAY " <>
164 + "configured, mail is written to the log instead of sent — the sign-in link is in `#{logs}`. Then:",
165 + promote},
166 + {"close", "Close registration",
167 + "Sign-ups were open for you. Back to the default — invite only — and restart:", close}
168 + ]
169 + end
170 +
171 + defp env_lines(v, db_host: db_host, data: data) do
172 + Enum.join(
173 + [
174 + "PHX_HOST=#{v.host}",
175 + "DATABASE_URL=ecto://gitgud:#{v.db_pass}@#{db_host}/gitgud",
176 + "SECRET_KEY_BASE=$(openssl rand -base64 48 | tr -d '\\n')",
177 + "GITGUD_RUNNER_JWT_SECRET=$(openssl rand -base64 48 | tr -d '\\n')",
178 + "GITGUD_SECRETS_MASTER_KEY=$(openssl rand -base64 32)",
179 + "GIT_REPOS_PATH=#{data}/repos",
180 + "STORAGE_DISK_ROOT=#{data}/storage",
181 + "SSH_SERVER_ENABLED=true",
182 + "SSH_PORT=2222",
183 + "SSH_HOST_KEY_DIR=#{data}/ssh",
184 + "REGISTRATION_MODE=open"
185 + ],
186 + "\n"
187 + )
188 + end
189 +
190 + # ── page ─────────────────────────────────────────────────────────────
191 +
192 + @impl true
193 + def render(assigns) do
194 + ~H"""
195 + <Layouts.app flash={@flash} current_scope={@current_scope}>
196 + <article class="max-w-3xl space-y-8 pt-6">
197 + <header class="space-y-3">
198 + <p class="font-mono text-xs uppercase tracking-[0.18em] text-accent">Self-host</p>
199 + <h1 class="text-4xl font-bold tracking-tight">
200 + Run your own<span class="text-primary">.</span>
201 + </h1>
202 + <p class="text-lg opacity-80">
203 + One release and a Postgres. Pick how you'd like to run it, tell it where it will live,
204 + and paste the steps in order.
205 + </p>
206 + </header>
207 +
208 + <nav id="run-methods" class="flex flex-wrap gap-2" aria-label="How to run it">
209 + <.link
210 + :for={{slug, label} <- @methods}
211 + id={"run-method-#{slug}"}
212 + patch={~p"/run-your-own/#{slug}"}
213 + class={[
214 + "btn btn-sm transition-colors",
215 + if(@method == slug, do: "btn-primary", else: "btn-ghost border border-base-300")
216 + ]}
217 + aria-current={@method == slug && "page"}
218 + >
219 + {label}
220 + </.link>
221 + </nav>
222 +
223 + <.form
224 + for={@config_form}
225 + id="run-config"
226 + phx-change="configure"
227 + class="grid sm:grid-cols-3 gap-3 rounded-box border border-base-300 bg-base-200 p-4"
228 + >
229 + <.input
230 + field={@config_form[:host]}
231 + label="Public host"
232 + placeholder="git.example.com"
233 + autocomplete="off"
234 + phx-debounce="150"
235 + />
236 + <.input
237 + field={@config_form[:email]}
238 + type="email"
239 + label="Your email"
240 + placeholder="you@example.com"
241 + autocomplete="off"
242 + phx-debounce="150"
243 + />
244 + <.input
245 + field={@config_form[:db_pass]}
246 + label="Database password"
247 + placeholder="change-me"
248 + autocomplete="off"
249 + phx-debounce="150"
250 + />
251 + </.form>
252 +
253 + <ol id="run-steps" class="space-y-6">
254 + <li :for={{{id, title, note, command}, i} <- Enum.with_index(@steps, 1)} id={"step-#{id}"}>
255 + <h2 class="flex items-center gap-3 font-semibold">
256 + <span class="grid size-7 place-items-center rounded-full bg-primary text-primary-content font-mono text-xs">
257 + {i}
258 + </span>
259 + {title}
260 + </h2>
261 + <p :if={note} class="mt-2 ml-10 text-sm opacity-75">{note}</p>
262 + <div class="relative mt-2 ml-10">
263 + <button
264 + type="button"
265 + id={"copy-#{id}"}
266 + phx-hook=".CopyCommand"
267 + data-target={"command-#{id}"}
268 + class="btn btn-ghost btn-xs absolute right-2 top-2 gap-1"
269 + aria-label={"Copy: #{title}"}
270 + >
271 + <.icon name="hero-clipboard-document-micro" class="size-4" />
272 + <span data-label>Copy</span>
273 + </button>
274 + <pre
275 + id={"command-#{id}"}
276 + class="overflow-x-auto rounded-box bg-base-300 p-4 pt-9 text-xs leading-relaxed sm:text-sm"
277 + ><code>{command}</code></pre>
278 + </div>
279 + </li>
280 + </ol>
281 +
282 + <script :type={Phoenix.LiveView.ColocatedHook} name=".CopyCommand">
283 + export default {
284 + mounted() {
285 + this.el.addEventListener("click", () => {
286 + const pre = document.getElementById(this.el.dataset.target)
287 + if (!pre || !navigator.clipboard) return
288 + navigator.clipboard.writeText(pre.textContent.trim()).then(() => {
289 + const label = this.el.querySelector("[data-label]")
290 + if (!label) return
291 + const prev = label.textContent
292 + label.textContent = "Copied!"
293 + setTimeout(() => { label.textContent = prev }, 1500)
294 + }).catch(() => {})
295 + })
296 + }
297 + }
298 + </script>
299 +
300 + <section class="space-y-2 text-sm opacity-75">
301 + <h2 class="font-semibold opacity-100">From here</h2>
302 + <ul class="list-disc pl-5 space-y-1">
303 + <li>
304 + Mail: set <code>SMTP_RELAY</code>, <code>SMTP_USERNAME</code>,
305 + <code>SMTP_PASSWORD</code>
306 + and <code>MAIL_FROM_ADDRESS</code>
307 + so sign-in links reach people instead of the log — where anyone who can read it can use them.
308 + </li>
309 + <li>
310 + CI: register a <code>forgejo-runner</code>
311 + from a repository's or organization's Actions settings — the page has a snippet per runtime.
312 + </li>
313 + <li>
314 + Behind a proxy that isn't on a private address, set <code>GITGUD_TRUSTED_PROXIES</code>
315 + so rate limits see clients rather than the proxy.
316 + </li>
317 + <li>
318 + Everything else — storage backends, federation, package registries, Kubernetes —
319 + is in the <a href={@source_url} class="link link-primary">README</a>.
320 + </li>
321 + </ul>
322 + </section>
323 + </article>
324 +
325 + <.site_footer />
326 + </Layouts.app>
327 + """
328 + end
329 +end
modified lib/git_gud_web/router.ex
+3 −0
@@ -269,6 +269,9 @@ defmodule GitGudWeb.Router do
269 269 # visibility was enforced inside each LiveView, and in eighteen of
270 270 # nineteen of them it wasn't.
271 271 live "/", PageLive.Home, :index
272 + # Self-hosting guide; anyone may read it.
273 + live "/run-your-own", PageLive.RunYourOwn, :index
274 + live "/run-your-own/:method", PageLive.RunYourOwn, :index
272 275 live "/repositories", RepoLive.Index, :index
273 276 # Page 2 onwards. Declared after /repositories/new and /import,
274 277 # which live in the authenticated scope above and so match first.
added test/git_gud/release_promote_admin_test.exs
+25 −0
@@ -0,0 +1,25 @@
1 +defmodule GitGud.ReleasePromoteAdminTest do
2 + use GitGud.DataCase, async: false
3 +
4 + import ExUnit.CaptureIO
5 + import GitGud.AccountsFixtures
6 +
7 + alias GitGud.Accounts.User
8 +
9 + test "makes the account with that email an admin, whatever its case" do
10 + user = user_fixture()
11 + refute user.is_admin
12 +
13 + assert capture_io(fn ->
14 + assert GitGud.Release.promote_admin(String.upcase(user.email)) == :ok
15 + end) =~ "promoted"
16 +
17 + assert Repo.get!(User, user.id).is_admin
18 + end
19 +
20 + test "says so when there's no such account" do
21 + assert capture_io(fn ->
22 + assert GitGud.Release.promote_admin("nobody@example.com") == :not_found
23 + end) =~ "no user"
24 + end
25 +end
added test/git_gud_web/live/page_live/run_your_own_test.exs
+68 −0
@@ -0,0 +1,68 @@
1 +defmodule GitGudWeb.PageLive.RunYourOwnTest do
2 + use GitGudWeb.ConnCase, async: true
3 +
4 + import Phoenix.LiveViewTest
5 +
6 + defp command(view, id), do: view |> element("#command-#{id}") |> render() |> text()
7 +
8 + defp text(html), do: html |> LazyHTML.from_fragment() |> LazyHTML.text()
9 +
10 + test "the landing page links to it next to the repository list", %{conn: conn} do
11 + {:ok, view, _html} = live(conn, ~p"/")
12 + assert has_element?(view, ~s(#quick-run-your-own[href="/run-your-own"]))
13 + end
14 +
15 + test "defaults to Podman, with every step and the image", %{conn: conn} do
16 + {:ok, view, _html} = live(conn, ~p"/run-your-own")
17 +
18 + assert has_element?(view, ~s(#run-method-podman[aria-current="page"]))
19 +
20 + for id <- ~w(db config start tls admin close),
21 + do: assert(has_element?(view, "#step-#{id}"))
22 +
23 + assert command(view, "start") =~ "podman run"
24 + assert command(view, "start") =~ "ggr.neiam.co/neiam/gitgud:latest"
25 + end
26 +
27 + test "the tabs are routes", %{conn: conn} do
28 + {:ok, view, _html} = live(conn, ~p"/run-your-own")
29 +
30 + view |> element("#run-method-docker") |> render_click()
31 + assert_patch(view, ~p"/run-your-own/docker")
32 + assert command(view, "db") =~ "docker network create gitgud"
33 +
34 + {:ok, view, _html} = live(conn, ~p"/run-your-own/debian")
35 + assert has_element?(view, "#step-apt")
36 + assert command(view, "apt") =~ "apt install gitgud"
37 + refute has_element?(view, "#command-db", "docker")
38 + end
39 +
40 + test "an unknown method falls back to Podman", %{conn: conn} do
41 + {:ok, view, _html} = live(conn, ~p"/run-your-own/kubernetes")
42 + assert has_element?(view, ~s(#run-method-podman[aria-current="page"]))
43 + end
44 +
45 + test "what's typed is filled into the commands", %{conn: conn} do
46 + {:ok, view, _html} = live(conn, ~p"/run-your-own")
47 +
48 + view
49 + |> form("#run-config",
50 + config: %{host: "forge.example.org", email: "me@example.org", db_pass: "s3cret"}
51 + )
52 + |> render_change()
53 +
54 + assert command(view, "config") =~ "PHX_HOST=forge.example.org"
55 + assert command(view, "config") =~ "DATABASE_URL=ecto://gitgud:s3cret@gitgud-db/gitgud"
56 + assert command(view, "db") =~ "POSTGRES_PASSWORD=s3cret"
57 + assert command(view, "admin") =~ ~s{promote_admin("me@example.org")}
58 + assert command(view, "tls") =~ "--from forge.example.org"
59 + end
60 +
61 + test "secrets are generated by the reader's shell, never by the page", %{conn: conn} do
62 + {:ok, view, _html} = live(conn, ~p"/run-your-own")
63 + config = command(view, "config")
64 +
65 + for key <- ~w(SECRET_KEY_BASE GITGUD_RUNNER_JWT_SECRET GITGUD_SECRETS_MASTER_KEY),
66 + do: assert(config =~ ~r/#{key}=\$\(openssl rand /)
67 + end
68 +end

Parents: c1a34d8