neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Package GitGud as a .deb, published to its own apt repository
d82bd83 · Gabriel Morell · 2026-09-12 04:53
Message
{commit_body(@commit)}
Files changed
modified
.forgejo/workflows/build.yml
+87
−0
| 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 | 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 | 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 | 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 | 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 | 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 | 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
| 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