neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Homepage: current features, and a Run your own guide
a81156c · Gabriel Morell · 2026-10-20 20:54
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