Builds Arch Linux packages with makepkg, indexes them into a pacman repository with
repo-add, and publishes the result from the gh-pages branch so GitHub Pages serves it
at https://lemonade-sdk.github.io/lemonade-packaging.
Everything runs in one workflow: .github/workflows/build-and-publish.yml.
# /etc/pacman.conf
[lemonade]
Server = https://lemonade-sdk.github.io/lemonade-packaging/arch/$arch
SigLevel = Never$ sudo pacman -Sy && sudo pacman -S lemonade-server-next lemonade-desktop-next$arch is substituted by pacman, so one Server line covers every architecture you
publish (the workflow currently publishes x86_64 only — see Extending).
.github/workflows/build-and-publish.yml # the pipeline
PKGBUILD # the one and only, at the repository root
lemonade-sysusers.patch # sources the PKGBUILD applies in prepare()
lemonade-web-app.patch
There is exactly one PKGBUILD and it lives at the repository root: the workflow builds
that file and nothing else — no discovery step and no per-subdirectory filter. It is a split
PKGBUILD (pkgbase=lemonade → lemonade-server + lemonade-desktop), so that single
build still publishes several archives. .git/ and the scratch directories — src/ (the
checkout), out/ (the built archives) and site/ (the gh-pages clone) — are never
committed. Adding a second package means adding it to this PKGBUILD's pkgbase, not
creating another file.
.nojekyll # stops Jekyll from mangling the tree
arch/index.html # human landing page + the pacman.conf snippet
arch/x86_64/lemonade.db -> lemonade.db.tar.gz # symlink created by repo-add
arch/x86_64/lemonade.db.tar.gz # the package database pacman downloads
arch/x86_64/lemonade.files.tar.gz # file listings for pacman -F
arch/x86_64/foo-1.2-3-x86_64.pkg.tar.zst # the packages themselves
REPO_NAME (default lemonade) is the name of the database, i.e. the string inside the
[...] header of your users' pacman.conf. PAGES_DIR (default arch) is the directory
below the Pages root.
| Event | Behaviour |
|---|---|
push to main touching PKGBUILD, *.patch or the workflow |
build + publish |
workflow_dispatch |
build + publish; rebuild_db drops the database first, lemonade_version pins DATE/COMMITS, force_bump re-checksums without a version change |
pull_request |
the full pipeline runs (so a broken PKGBUILD is caught) but nothing is pushed |
schedule (30 16 * * 3, Wednesdays 16:30 UTC) |
follow upstream's release branch: bump DATE and COMMITS, rebuild, republish |
Keep the schedule after upstream's release cut — upstream branches release-v<year>.<week>
off main at 16:00 UTC on the Wednesday of the cut
(release.md, "Release Cadence and Channels"), and a run that fires before the
new branch exists resolves last week's release instead. 0 15 * * 4 (Thursday 15:00 UTC) is
the slot with a day of slack around the cut and is kept commented out in the workflow.
GitHub disables a schedule on a branch after 60 days without a commit; push anything or
re-enable it in Settings → Actions. The weekly bump commit is itself a commit to main, so
in practice the schedule keeps itself alive.
Upstream ships date-based versions — year.week.number
(release.md, "Versioning") — so a new release appears every week and each one
needs its own build here. The root PKGBUILD carries two variables and derives the rest:
DATE=2026.39 # the release-v<DATE> branch to build
COMMITS=1 # commits added to it since it was cut
pkgver=${DATE}.${COMMITS}rc # -> 2026.39.1rcCOMMITS is upstream's number: commits added to the release branch after it was branched
from main, and 0 immediately after a cut. Keeping the pair here — rather than a full
version string — means one pair of values decides both the branch that gets cloned and the
version its packages are named.
Those lines are rewritten by the Bump DATE and COMMITS… step, which runs on the schedule
and on workflow_dispatch (never on push/pull_request — those build exactly what is
committed):
git ls-remote --heads https://github.com/lemonade-sdk/lemonade.git refs/heads/release-v*—DATEbecomes the newest release branch, which is the release upstream is currently working on (a branch that ends up untagged is a skipped stable release, not a deleted branch, so the newest one is always the live one).GET /repos/lemonade-sdk/lemonade/compare/main...release-v<DATE>→total_commits, taken asCOMMITS. The call is unauthenticated becauseGITHUB_TOKENis scoped to this repository and cannot read upstream's (two requests a week stay far under the 60 req/h limit).- If the pair already matches the PKGBUILD the step stops here — no rewrite, no re-checksum,
no commit — unless
force_bumpis set. Otherwise both lines are rewritten andupdpkgsumsregenerates theb2sumsarray, because thegit+…#branch=${LEMONADE_RELEASE_BRANCH}entry in it is hashed content: the moment the branch moves the checksum is wrong andmakepkgrefuses to build. The clone lands inSRCDEST($RUNNER_TEMP/srcdest), which the build step reuses, so upstream is downloaded once per run. - Once the packages have been built and published,
Push the version bumpcommits justPKGBUILDto the branch the workflow was running on. If the branch moved while the build was running it rebases the single commit and retries once; a genuine conflict aborts with an annotation and leaves the remote untouched.
Nothing is committed unless a build of the new version succeeded, so main never points at a
version this repository cannot build — a failed week is simply retried by the next run, which
resolves the newest release branch again (the same one if nothing was cut since).
The bump and the build have to share a run: a push made with GITHUB_TOKEN does not
trigger another workflow run, so a bump-only commit would otherwise sit un-built until some
other change landed. To take a release out of rotation, run the workflow by hand with
lemonade_version set to year.week.number (or year.week, which means .0);
force_bump rewrites and re-checksums the PKGBUILD without changing the version.
- Toolchain —
pacman -Sy archlinux-keyring && pacman -Su base-devel git sudoinsideghcr.io/archlinux/archlinux:latest. - Build user —
makepkgrefuses to run as root and that image defaults to root, so abuilduser is created with aNOPASSWDsudoers line (needed bymakepkg -sto install makedepends), and the checkout ischowned to it. - Checkout — plain
git(fetch --depth 1+checkout --detach) instead ofactions/checkout. JavaScript actions need a Node.js runtime inside the container and Arch images do not ship one. Forpull_request,refs/pull/N/mergeis fetched so the tested tree matches GitHub's merge preview. - Check —
src/PKGBUILDexists. There is exactly one PKGBUILD, at the repository root, so nothing is discovered and nopackagefilter is honoured; a missing file means the checkout is wrong and fails immediately instead of confusingmakepkgminutes later. - Build —
makepkg -sc --noconfirmon that PKGBUILD withPKGEXT=.pkg.tar.zstandSOURCE_DATE_EPOCHset to the commit time (reproducible archives, no needless churn). The splitpkgbasestill yields one archive perpkgname. A failing build fails the run and withholds the pending version bump. - Restore — clone
gh-pagesif it exists (install -d siteotherwise) andtouch site/.nojekyll. The database refers to package files by name, so the files already published have to be on disk before it is rewritten. - Create / update the database —
repo-add -p -R "$REPO_NAME.db.tar.gz" *.pkg.tar.*.repo-addcreates the.db/.filespair when it is missing and updates it in place otherwise.-prefuses to replace a newer published version;-Rdeletes archives that the new one supersedes. The.db.old/.files.oldbackupsrepo-addleaves behind are deleted. If the entries turn out identical to what is already published, the published bytes are restored —repo-addre-stamps its output with the current time, and without that step every scheduled run would create a commit. - Verify — extract every
%FILENAME%from the database and fail if a listed archive is missing; delete archives no entry points at (e.g. a buildrepo-add -prefused), so the served tree and the database can never disagree. - Index — render
arch/index.htmlwith thepacman.confsnippet and links to every archive. Deliberately byte-stable so a no-op run stays a no-op. - Publish —
git add, commit,git push origin HEAD:refs/heads/gh-pages— skipped for pull requests, skipped when the tree is byte-identical. - Report — a run-summary note with counts and the
Serverline.
- Settings → Pages → Source: “Deploy from a branch”, branch
gh-pages, folder/ (root). The workflow writes thearch/directory itself; it does not create the branch until the first successful run, so enable Pages after the first run, or create an emptygh-pagesbranch first — either order works. - Settings → Actions → General → Workflow permissions: “Read and write”.
GITHUB_TOKENcannot be granted more than this cap, and the publish push needscontents: write. - Optional: protect
gh-pagesfrom force-pushes. The workflow only ever fast-forwards (it clones the tip and adds one commit).
concurrency.group: arch-repo-publish with cancel-in-progress: false serialises runs,
because each one rewrites the same branch. A run that starts while another is publishing
would otherwise be rejected by the remote as a non-fast-forward.
- Package signing — add
--signtomakepkg(needs a key in the runner) and-s -k <keyid>torepo-add, then ship the key and useSigLevel = Requiredfor users. Store the private key in an Actions secret and import it in a step before the build. - Another architecture — the job hardcodes
ARCH: x86_64andruns-on: ubuntu-latest. Duplicate the job ontoubuntu-24.04-arm(native arm64 runners are free for public repos) withARCH: aarch64; both jobs write intoarch/<arch>/, so they touch disjoint paths. Give each job its ownconcurrencygroup if you do. - A second repository/database — change
REPO_NAME; users add a second[repo]block pointing at the sameServerpath. actions/deploy-pagesinstead of pushing a branch — the build job must then hand the tree to a second job on a runner that has Node.js, becauseactions/upload-pages-artifactandactions/deploy-pagesare JavaScript actions and cannot execute inside the Arch container. The current single-job git-push design avoids that split; the upside of switching is that Pages keeps its history in GitHub's artifact store instead of a branch.- Cache — wrap the build with
actions/cacheover~/.cache/makepkg(orBUILDDIR) if rebuilds get expensive; it is a JS action, so it would need the same two-job split, or--skipinteg-style local caching.
SOURCE_DATE_EPOCHmakes archives reproducible, butrepo-adddoes not honour it for its own timestamps — that is what the “unchanged” restore in step 7 is for.- GitHub Pages serves
https://lemonade-sdk.github.io/lemonade-packaging/arch/x86_64/lemonade.dbbecauserepo-addleaveslemonade.dbas a symlink tolemonade.db.tar.gz; git stores it as a symlink and Pages serves it as a file. Both URLs work. - The repository is unsigned (
SigLevel = Never). pacman warns; that is expected until signing is wired up. out/andsite/are scratch directories created inside the workspace by the job and are git-ignored.site/is its own git repository (a clone ofgh-pages, or a freshgit init -b gh-pages), so thegit add -Ain the publish step can only ever stage files under it — nothing from your source tree can leak onto Pages.