Builds some AURs and my libfprint package for my laptop, pushing the diff to xerootg.github.io
Saves me time building AURs every time someone pushes.
xerootg.github.io lists everything in every repo below — versions, sizes, build dates, when each package was first published, and a rolling changelog. It is generated rather than written; see the package index.
Packages are signed. Trust the key once, then add the repo to your
pacman.conf:
curl -fsSL https://xerootg.github.io/xerootg.asc | sudo pacman-key --add -
sudo pacman-key --lsign-key "$(curl -fsSL https://xerootg.github.io/xerootg.asc \
| gpg --show-keys --with-colons | awk -F: '/^fpr:/{print $10; exit}')"[custom]
Server = https://xerootg.github.io/archlinux
SigLevel = Required DatabaseOptional--lsign-key is the step people miss: --add imports the key, but pacman still
refuses every package until the key is locally signed as trusted. The site
carries the same snippet with the fingerprint filled in.
ghidra-noprompt is ~500 MB, and GitHub refuses any pushed file over 100 MB, so
it cannot live in the Pages repo above. It gets its own pacman repo, served
directly out of a GitHub Release on this repository:
[ghidra]
Server = https://github.com/xerootg/arch/releases/download/pacman-repo
SigLevel = Required DatabaseOptionalThen sudo pacman -Sy ghidra-noprompt.
That is stock Ghidra from
the matching upstream stable release, with one change: opening a program that
has never been analyzed no longer prompts "would you like to analyze it now?".
The prompt moves to a tool option (Edit → Tool Options → Auto Analysis → Ask
To Analyze) that ships disabled, so it can be turned back on without a
rebuild. The patch and the build that produced it live in
xerootg/ghidra. The package
provides/conflicts with ghidra.
.github/workflows/ghidra-noprompt.yml checks that fork daily, repackages any
new release, refreshes the ghidra.db in the fixed pacman-repo release, and
drops the superseded package asset. The release tag never changes, so the
Server URL above is stable.
Its PKGBUILD lives in release-pkgbuilds/, not pkgbuilds/, so the
build-pacman-repo pipeline never sees it — that pipeline would otherwise try
to commit the package to the Pages repo and break the push for every other
package.
The job decides what to build from the release itself, not from the checked-in
pkgver, so it stays correct without needing to commit anything. It does try to
refresh the PKGBUILD afterwards, but the main ruleset requires a pull request
and github-actions[bot] cannot bypass it, so that push is best effort and
non-fatal — expect the checked-in recipe to lag a release behind unless the bot
is granted a bypass.
orca-slicer-git compresses to ~164 MB, so it hits the same 100 MB wall. It gets
its own release-hosted repo:
[orca]
Server = https://github.com/xerootg/arch/releases/download/orca-repo
SigLevel = Required DatabaseOptionalThen sudo pacman -Sy orca-slicer-git.
Ships orca-slicer-git and orca-slicer-git-extras (calibration models, daily
tips, profile validator). The main package provides/conflicts with
orca-slicer.
.github/workflows/orca-slicer.yml computes upstream's pkgver the same way the
PKGBUILD does, skips the run if that revision is already published, and otherwise
does a full from-source build — around 2h45m — before refreshing orca.db in
the fixed orca-repo release and deleting the superseded packages.
Two settings keep that build alive on a 16 GB runner, both in
the PKGBUILD: options=('!lto') at
pkgbase level, and ninja -j2 for the main build. The pkgbase part matters —
an options=() inside a package_*() function only affects packaging, and
makepkg resolves lto before build() runs, so a per-package array cannot
disable it. Without this the job is OOM-killed (exit 137) while compiling
libslic3r's precompiled header, and because that kill takes the whole container
down rather than returning a non-zero status, allow-failure does not contain
it — it stops every other package in the repo from updating too.
xerootg.github.io is regenerated by
.github/workflows/repo-index.yml and never edited by hand.
Its input is each repo's pacman database — the .db tarball repo-add
writes at the end of every build. That file already carries the name, version,
description, URL, licence, compressed and installed size, and %BUILDDATE% of
every package, and it is the same file pacman downloads, so the page cannot
disagree with what a client would actually install. Nothing had to be added to
the builders to produce it.
A .db is a snapshot with no history, though, so "when was this added" cannot
be recovered from it. That is what the CSVs in the Pages repo are for. They
start empty and accumulate:
| file | contents |
|---|---|
data/packages.csv |
one row per (repo, package): current version, sizes, built_utc, first_seen_utc, last_updated_utc |
data/changes.csv |
append-only log of added / updated / removed, capped at 5000 rows |
data/state.json |
last check time, and a digest of the data |
first_seen_utc is written once, the first time a package is observed, and
never rewritten. The first run records its packages as seeded rather than
added, because their real history predates the tool and claiming otherwise
would be a lie the CSV then carries forever. built_utc comes straight from the
database and is accurate from day one.
Rendering reads only the CSVs, never the databases. A repo whose .db could not
be fetched this run keeps its last known contents on the page instead of
silently emptying out, and logs no spurious removals — one failed release
download must not rewrite history.
The job runs after each of the three publishers finishes (workflow_run), plus
once daily. It commits only when the data actually changed, or when the last
commit is over 20 hours old — enough to keep the "last checked" stamp honest
without a commit per trigger, in a repo that has already had to be rewritten
once for size.
It is a separate workflow rather than a step inside each publisher on purpose: those run on different schedules, take between two minutes and three hours, and two of them never check out the Pages repo. One writer avoids the race.
The site carries a .nojekyll, so Pages serves it statically. There is no build
step between publishing a package and it being downloadable, which matters when
the site is mostly a few hundred megabytes of .pkg.tar.zst.
Every package and database is signed, so clients can run SigLevel = Required DatabaseOptional — the same setting stock /etc/pacman.conf uses — instead of
Optional TrustAll. The current fingerprint and a ready-to-paste import command
are on xerootg.github.io; the public key is served
at /xerootg.asc.
.github/scripts/sign-pacman-repo.sh does all of it, for all three repos. It
needs only gpg and coreutils, so the same script runs inside the Arch build
containers and directly on the Ubuntu runner for backfills.
Two things drove the design:
build-pacman-repo cannot sign. It shells out to repo-add --quiet --nocolor with no way to pass --sign, and has no GPG support at all. So
signing is a separate pass over the repository directory rather than something
threaded through the builder.
A pass over the directory is also the only correct approach. Once
SigLevel = Required is in play, pacman wants a .sig beside every package,
including ones that were not rebuilt this run. Signing only what a build
produced would leave the untouched packages permanently unverifiable.
The script is idempotent and rotation-aware: an existing signature is kept only if it still verifies against the key currently loaded, so changing the secret and re-running re-signs whatever no longer checks out.
The database is rewritten on every build. If the signing key were ever missing
while signatures were already present, the old .db.sig would no longer match
the new .db, and pacman rejects a repo with a bad database signature far
more harshly than one with none — every client breaks at once. The script
refuses to run in that state rather than publishing it.
.github/workflows/sign-backfill.yml signs what is already published without
rebuilding it. The release-hosted repos only rebuild when upstream moves —
ghidra roughly monthly — so without this, turning signing on would leave those
packages unverifiable for weeks. It downloads the release assets, signs
whatever is missing or stale, uploads the .sig files, and refreshes the
release notes. It is also the tool to run after rotating the key.
Release notes now live in .github/release-notes/. They used to be inline
heredocs that only ran when a release was first created, which meant the
install instructions published on an existing release could never be corrected.
build-repo-index.py reads the issuer fingerprint out of each <repo>.db.sig
with gpg --list-packets. No secret is involved — the issuer is cleartext in
the signature packet — and it is the one source that cannot drift, because it
is whatever key actually signed the database clients download. A repo is
advertised as Required only when its own database signature verifies as
present, so the page can never tell someone to enable a setting that would
then reject the repo.
| secret | contents |
|---|---|
GPG_SIGNING_KEY |
ASCII-armoured signing subkey, secret half |
GPG_PASSPHRASE |
passphrase for it |
Repository-level secrets, so all four workflows can read them. Only the signing subkey is exported — the certifying primary key stays offline, so a compromise of CI costs a subkey that can be revoked without abandoning the identity or asking every user to re-trust a new fingerprint.