Package GitGud as a .deb, published to its own apt repository

d82bd83 · Gabriel Morell · 2026-09-12 04:53

24 files +803 -14
Message
{commit_body(@commit)}

Files changed

modified .forgejo/workflows/build.yml
+87 −0
@@ -43,3 +43,90 @@ jobs:
43 43 labels: ${{ steps.meta.outputs.labels }}
44 44 cache-from: type=registry,ref=${{ vars.CI_REGISTRY }}/${{ github.repository }}:buildcache
45 45 cache-to: type=registry,ref=${{ vars.CI_REGISTRY }}/${{ github.repository }}:buildcache,mode=max
46 +
47 + # The forge packages itself for the forge's own apt repository —
48 + # slice 70's registry, used the way an operator would. `deb` runs
49 + # after `build` so it reuses that job's buildx cache rather than
50 + # compiling the release a second time from cold.
51 + deb:
52 + runs-on: self-hosted
53 + needs: [build]
54 + steps:
55 + - uses: actions/checkout@v4
56 +
57 + - uses: docker/setup-buildx-action@v3
58 +
59 + # Just the OTP release, no image around it: the `release-export`
60 + # stage is `FROM scratch`, so this lands the release tree and
61 + # nothing else in ./release.
62 + - name: Build the release
63 + run: |
64 + docker buildx build \
65 + --target release-export \
66 + --output type=local,dest=./release \
67 + --cache-from type=registry,ref=${{ vars.CI_REGISTRY }}/${{ github.repository }}:buildcache \
68 + --file ./Dockerfile .
69 +
70 + # Same script a developer runs locally — see packaging/deb/.
71 + # One release, one .deb per suite. The suites differ only in the
72 + # version suffix and the dist they're published to — a trixie-built
73 + # release runs on forky (glibc and the libssl/libncurses sonames
74 + # are compatible forward), and `hexpm/elixir` publishes no
75 + # forky-based builder image to compile a native one against.
76 + - name: Build the .deb for each suite
77 + id: deb
78 + env:
79 + FORGE_URL: ${{ vars.CI_FORGE_URL }}
80 + REPOSITORY: ${{ github.repository }}
81 + COMMIT: ${{ github.sha }}
82 + SUITES: trixie forky
83 + run: |
84 + set -euo pipefail
85 + # The maintainer address is this forge's own host — the
86 + # package has no upstream maintainer other than the instance
87 + # that built it.
88 + host=${FORGE_URL#*://}
89 + export HOMEPAGE="$FORGE_URL/$REPOSITORY"
90 + export MAINTAINER="GitGud CI <noreply@${host%%/*}>"
91 +
92 + for suite in $SUITES; do
93 + package=$(SUITE="$suite" packaging/deb/build-deb.sh ./release dist)
94 + echo "$suite $package" >> dist/packages.txt
95 + dpkg-deb --info "$package"
96 + done
97 +
98 + cat dist/packages.txt
99 +
100 + # `${{ github.token }}` is minted for this job and authorized to
101 + # publish to THIS repository's packages and nowhere else, so
102 + # there is no secret to create first — the same deal the OCI
103 + # registry gives (GitGud.Packages.Auth).
104 + - name: Publish to the apt repository
105 + env:
106 + FORGE_URL: ${{ vars.CI_FORGE_URL }}
107 + REPOSITORY: ${{ github.repository }}
108 + TOKEN: ${{ github.token }}
109 + run: |
110 + set -euo pipefail
111 + while read -r suite package; do
112 + echo "publishing $package to $suite"
113 + curl --fail-with-body -sS \
114 + -u "gitgud-actions:$TOKEN" \
115 + -H "Content-Type: application/vnd.debian.binary-package" \
116 + --data-binary "@$package" \
117 + "$FORGE_URL/pkg/deb/$REPOSITORY/upload?dist=$suite&component=main"
118 + echo
119 + done < dist/packages.txt
120 +
121 + - name: How to install it
122 + env:
123 + FORGE_URL: ${{ vars.CI_FORGE_URL }}
124 + REPOSITORY: ${{ github.repository }}
125 + OWNER: ${{ github.repository_owner }}
126 + run: |
127 + cat <<EOF
128 + # \$(lsb_release -cs) is trixie or forky
129 + curl -fsSL $FORGE_URL/pkg/_keys/deb/$OWNER | sudo tee /etc/apt/keyrings/$OWNER.asc >/dev/null
130 + echo "deb [signed-by=/etc/apt/keyrings/$OWNER.asc] $FORGE_URL/pkg/deb/$REPOSITORY \$(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/gitgud.list
131 + sudo apt update && sudo apt install gitgud
132 + EOF
modified Dockerfile
+15 −4
@@ -37,10 +37,10 @@ RUN apt-get update \
37 37 build-essential git pkg-config libssl-dev libclang-dev curl ca-certificates \
38 38 && rm -rf /var/lib/apt/lists/*
39 39
40 −# Rust via rustup, pinned. apt's rust on trixie is 1.85, but recent
41 −# transitive gix deps (icu_*, home) require 1.86+; rustup keeps the
42 −# compiler decoupled from the OS rev. Pinned to a known-good rev so
43 −# builds are reproducible.
40 +# Rust via rustup, pinned. apt's rust on trixie is 1.85, but the gix
41 +# dependency tree requires 1.87+; rustup keeps the compiler decoupled
42 +# from the OS rev. Pinned to a known-good rev so builds are
43 +# reproducible.
44 44 ENV RUSTUP_HOME=/usr/local/rustup \
45 45 CARGO_HOME=/usr/local/cargo \
46 46 PATH=/usr/local/cargo/bin:${PATH} \
@@ -104,6 +104,17 @@ RUN mix sentry.package_source_code
104 104
105 105 RUN mix release
106 106
107 +# The release tree on its own, for anything that wants the bits without
108 +# the image around them — `packaging/deb/build-deb.sh` turns this into a
109 +# .deb in CI:
110 +#
111 +# docker buildx build --target release-export \
112 +# --output type=local,dest=./release .
113 +#
114 +# `scratch` so the export is exactly the release and nothing else.
115 +FROM scratch AS release-export
116 +COPY --from=builder /app/_build/prod/rel/git_gud /
117 +
107 118 # start a new build stage so that the final image will only contain
108 119 # the compiled release and other runtime necessities
109 120 FROM ${RUNNER_IMAGE} AS final
modified README.md
+56 −0
@@ -460,6 +460,62 @@ sends Basic with `__token__` in the username, `mix` and `cargo` send
460 460 the bare token, apt reads `/etc/apt/auth.conf.d/`. A 401 carries a
461 461 Basic challenge, which `pip` and `apt` retry against.
462 462
463 +### GitGud ships itself as a .deb
464 +
465 +The most honest test of a registry is using it, so CI builds an
466 +installable Debian package of the forge and publishes it to the forge's
467 +own apt repository — `deb` job in `.forgejo/workflows/build.yml`, no
468 +stored credential, the per-job token again.
469 +
470 +Two suites are published — **trixie** and **forky** — from one build.
471 +They are the same bytes under two versions (`…+trixie`, `…+forky`), one
472 +per pool filename: a trixie-linked release runs on forky, and
473 +`hexpm/elixir` ships no forky-based builder image to compile a native
474 +one against. When it does, the suites become real per-suite builds and
475 +nothing about the `deb` lines changes.
476 +
477 +The package is a self-contained OTP release (the Erlang runtime is
478 +bundled, so no Elixir or Erlang install is needed) plus a systemd unit
479 +and one config file:
480 +
481 +```
482 +/opt/gitgud/ the release — bin/, lib/, releases/, erts-*
483 +/etc/gitgud/gitgud.env configuration; a conffile, so upgrades keep your edits
484 +/lib/systemd/system/gitgud.service
485 +/var/lib/gitgud/{repos,storage,tmp} created by postinst, owned by the `gitgud` user
486 +```
487 +
488 +Installing it:
489 +
490 +```sh
491 +curl -fsSL https://<host>/pkg/_keys/deb/<owner> \
492 + | sudo tee /etc/apt/keyrings/<owner>.asc > /dev/null
493 +# $(lsb_release -cs) resolves to trixie or forky
494 +echo "deb [signed-by=/etc/apt/keyrings/<owner>.asc] https://<host>/pkg/deb/<owner>/gitgud $(lsb_release -cs) main" \
495 + | sudo tee /etc/apt/sources.list.d/gitgud.list
496 +sudo apt update && sudo apt install gitgud
497 +
498 +sudoedit /etc/gitgud/gitgud.env # DATABASE_URL, SECRET_KEY_BASE, PHX_HOST
499 +sudo /opt/gitgud/bin/migrate
500 +sudo systemctl start gitgud
501 +```
502 +
503 +The package is enabled but **not started** on install: the shipped
504 +config has `CHANGEME` in it, and starting against that would only
505 +produce a crash loop in the journal.
506 +
507 +`packaging/deb/build-deb.sh` is what CI runs, and it takes a release
508 +directory, so the same package builds locally:
509 +
510 +```sh
511 +MIX_ENV=prod mix release
512 +packaging/deb/build-deb.sh _build/prod/rel/git_gud
513 +```
514 +
515 +Provenance is in the control file — every build records the commit it
516 +came from — and the version sorts as a pre-release
517 +(`0.1.0~ci<date>.g<sha>`), so a tagged `0.1.0` upgrades over it cleanly.
518 +
463 519 ### The apt NIF
464 520
465 521 apt is the one format that needed native code: it requires an OpenPGP
modified lib/git_gud_web/controllers/runner_controller.ex
+24 −10
@@ -192,6 +192,7 @@ defmodule GitGudWeb.RunnerController do
192 192
193 193 defp build_task(job) do
194 194 run = GitGud.Repo.get!(GitGud.Workflows.WorkflowRun, job.workflow_run_id)
195 +
195 196 repo =
196 197 GitGud.Repo.get!(GitGud.Repositories.Repository, run.repository_id)
197 198 |> GitGud.Repo.preload([:owner, :organization])
@@ -215,15 +216,26 @@ defmodule GitGudWeb.RunnerController do
215 216 }
216 217 end
217 218
218 − # Built-in CI variables the forge injects into every job. CI_REGISTRY
219 − # is the bundled OCI registry host (config :git_gud, :registry_host,
220 − # from GITGUD_REGISTRY_HOST) so workflows can `docker login`/push
221 − # without per-repo config. nil host → not injected.
222 − defp builtin_vars do
223 − case Application.get_env(:git_gud, :registry_host) do
224 − host when is_binary(host) and host != "" -> %{"CI_REGISTRY" => host}
225 − _ -> %{}
226 − end
219 + # Built-in CI variables the forge injects into every job.
220 + #
221 + # * CI_REGISTRY — the bundled OCI registry host (config :git_gud,
222 + # :registry_host, from GITGUD_REGISTRY_HOST) so workflows can
223 + # `docker login`/push without per-repo config. nil host → not
224 + # injected.
225 + # * CI_FORGE_URL — this forge's own base URL, so a workflow can
226 + # reach the package registries (`/pkg/...`) and the REST API with
227 + # the per-job token and no per-repo config either.
228 + #
229 + # Both are overridable: `CI_*` is deliberately not a reserved prefix.
230 + @doc false
231 + def builtin_vars do
232 + registry =
233 + case Application.get_env(:git_gud, :registry_host) do
234 + host when is_binary(host) and host != "" -> %{"CI_REGISTRY" => host}
235 + _ -> %{}
236 + end
237 +
238 + Map.put(registry, "CI_FORGE_URL", GitGudWeb.Endpoint.url())
227 239 end
228 240
229 241 # Always send an EMPTY needs map. forgejo-runner's generateWorkflow
@@ -387,7 +399,9 @@ defmodule GitGudWeb.RunnerController do
387 399 # complete (job long since reaped, db rolled back, etc.).
388 400 # We log the miss for forensics but echo back what the runner
389 401 # told us so it can stop pestering and free the worker slot.
390 − Logger.warning("update_task lookup miss — acking anyway for state.id=#{inspect(job_id)}")
402 + Logger.warning(
403 + "update_task lookup miss — acking anyway for state.id=#{inspect(job_id)}"
404 + )
391 405
392 406 respond(conn, %UpdateTaskResponse{state: state, sent_outputs: []})
393 407
added packaging/deb/README.md
+89 −0
@@ -0,0 +1,89 @@
1 +# Debian packaging
2 +
3 +The forge, as an installable `.deb`. CI builds it on every push to
4 +master and publishes it to *this forge's own* apt repository — which is
5 +the point: the registry that ships in slice 70, used the way an
6 +operator would use it.
7 +
8 +```
9 +control.in the control stanza; @PLACEHOLDERS@ filled by build-deb.sh
10 +gitgud.service systemd unit — runs /opt/gitgud/bin/server as `gitgud`
11 +gitgud.env configuration, installed as a dpkg conffile
12 +postinst creates the service user + /var/lib/gitgud, enables the unit
13 +prerm/postrm stop, disable; never deletes repositories or config
14 +build-deb.sh assembles all of the above around an OTP release
15 +```
16 +
17 +## Building one
18 +
19 +`build-deb.sh` takes a directory `mix release` produced, so the package
20 +CI publishes is the package you can build by hand:
21 +
22 +```sh
23 +MIX_ENV=prod mix release
24 +packaging/deb/build-deb.sh _build/prod/rel/git_gud dist
25 +```
26 +
27 +CI gets that directory out of the `release-export` stage in the
28 +`Dockerfile` instead of installing a build toolchain on the runner:
29 +
30 +```sh
31 +docker buildx build --target release-export --output type=local,dest=./release .
32 +packaging/deb/build-deb.sh ./release dist
33 +```
34 +
35 +That stage is `FROM scratch`, so the export is the release tree and
36 +nothing else, and it shares the image build's cache — the `deb` job
37 +`needs: [build]` for exactly that reason.
38 +
39 +## Choices worth knowing
40 +
41 +**The release bundles ERTS.** No `erlang` dependency, no version
42 +skew with the distro's OTP. The trade is that the package is built for
43 +one Debian release — the `Dockerfile`'s runner image, trixie — and its
44 +`Depends` are the handful of things the release genuinely can't carry:
45 +`git` and `curl` (the Cli git backend and the push hooks shell out to
46 +them), `openssh-client` for `ssh-keygen`, plus `libstdc++6`,
47 +`libncurses6`, `openssl` and `ca-certificates`. Verified by booting the
48 +exported release in a bare `debian:trixie-slim` container with exactly
49 +that list installed and nothing else.
50 +
51 +**The unit sets `LANG=C.UTF-8`.** systemd starts services with no locale
52 +at all, and the BEAM then comes up with native name encoding `latin1` —
53 +Elixir warns about it on every boot and non-ASCII filenames mangle. The
54 +Dockerfile solves this with `locales` + `locale-gen en_US.UTF-8`; a
55 +package shouldn't need to generate a locale on the target machine, and
56 +`C.UTF-8` is built into glibc, so `locales` isn't a dependency here.
57 +
58 +**Installed, enabled, not started.** The shipped `gitgud.env` has
59 +`CHANGEME` in it. Starting against that produces a crash loop in the
60 +journal and nothing useful, so the last line of the install is the
61 +operator's: edit the file, run `/opt/gitgud/bin/migrate`, then
62 +`systemctl start gitgud`.
63 +
64 +**Purge keeps the data.** `/var/lib/gitgud` holds every repository,
65 +package and uploaded blob on the machine, and `/etc/gitgud` holds the
66 +credentials. An accidental `dpkg -P` should not be the same thing as
67 +losing them, so `postrm purge` says where they are rather than deleting
68 +them.
69 +
70 +**Versions sort as pre-releases.** `0.1.0~ci20260912…g<sha>`; `~` sorts
71 +*before* `0.1.0`, so a future tagged release upgrades over CI builds
72 +instead of being shadowed by them. The date is the *commit's*, not the
73 +clock's, so rebuilding a commit reproduces its version.
74 +
75 +**One build, two suites.** `SUITE=<suite>` appends `+<suite>` to the
76 +version, and CI runs the script once per suite (trixie, forky) around a
77 +single release build. The two packages are the same bytes: a
78 +trixie-linked release runs on forky — glibc is forward-compatible and
79 +the `libssl` / `libncurses` sonames match — and `hexpm/elixir` publishes
80 +no forky-based builder image to compile a native one against. The suffix
81 +is what gives each suite its own pool filename, since a `.deb` is
82 +identified by `<name>_<version>_<arch>.deb` and one file can only be in
83 +one suite's index. If a forky builder image appears, only the release
84 +build changes; the suffix, the dists and the `deb` lines stay as they
85 +are.
86 +
87 +`test/git_gud_web/gitgud_deb_package_test.exs` renders `control.in` and
88 +pushes a package built from it through the forge's own apt registry, so
89 +a broken control stanza fails the suite rather than `apt install`.
added packaging/deb/build-deb.sh
+107 −0

Click to load diff…

added packaging/deb/control.in
+20 −0

Click to load diff…

added packaging/deb/gitgud.env
+46 −0

Click to load diff…

added packaging/deb/gitgud.service
+43 −0

Click to load diff…

added packaging/deb/postinst
+46 −0

Click to load diff…

added packaging/deb/postrm
+28 −0

Click to load diff…

added packaging/deb/prerm
+8 −0

Click to load diff…

modified test/git_gud_web/controllers/runner_controller_test.exs
+18 −0

Click to load diff…

added test/git_gud_web/gitgud_deb_package_test.exs
+216 −0

Click to load diff…

Parents: a366332