pi setup

Releases

Releases: sandbox

pi-docker-sandbox and pi-config are released with semantic-release on every push to main, in CI on a self-hosted Forgejo runner. The brain repos are not versioned (they are content stores).

RepoReleased asArtifacts
pi-docker-sandboxv<version> tag + Codeberg releaseCHANGELOG.md, VERSION, image codeberg.org/black0ut-pi-setup/pi-sandbox:<version> (linux/amd64 + linux/arm64)
pi-configv<version> tag + Codeberg releaseCHANGELOG.md

How a version is chosen#

From the Conventional Commits since the last tag (conventionalcommits preset):

CommitRelease
feat: …minor
fix: …, perf: …, build: …, chore(deps): …patch
BREAKING CHANGE: footer or type!:major
docs:, chore:, refactor:, test:, ci:, style:none

Version bumps of pi, the base image or packages are therefore committed as build(deps): bump <component> to <version> so they produce a patch release (and, here, a new image). The release commit itself is chore(release): <version> [skip ci], so it does not trigger another run.

What the pipeline does (.forgejo/workflows/release.yml)#

Release pipeline of pi-docker-sandbox. A push to main runs just check. Only if it passes, semantic-release picks the version, builds the image before anything is tagged, prunes old image versions, commits and tags, pushes the image and creates the Codeberg release. If main moved meanwhile, release-retry.sh runs it again on the new main.
  1. check — installs the pinned check tools (scripts/ci-install-tools.sh: bats, jq, shellcheck from apt; just and gitleaks at pinned versions with SHA-256 verification, kept current by Renovate — dependencies.md) and runs just check. No release if it fails.
  2. release — npm ci (tooling pinned in package.json / package-lock.json), registry login, then npx semantic-release (.releaserc.json):
    • analyse commits, generate notes, update CHANGELOG.md;
    • sandbox only: write the version to VERSION and build the multi-arch image (scripts/release-image.sh build <version>) — before anything is committed or tagged, so a failed build leaves no release behind;
    • sandbox only: prune old image versions (scripts/prune-images.sh --keep 1) — see Image retention;
    • commit CHANGELOG.md (and VERSION) and push the v<version> tag;
    • sandbox only: push the image (scripts/release-image.sh push <version>);
    • create the Codeberg release (@saithodev/semantic-release-gitea, Forgejo-compatible).

When main moves during a release#

The image is built in semantic-release’s prepare step, minutes before the release commit is pushed. A merge to main in that window (a Renovate PR, one of yours) makes the final push fail with ! [rejected] HEAD -> main (fetch first); nothing is tagged or published, but the run is red and the work is lost. scripts/release-retry.sh (the workflow’s release step) therefore runs semantic-release and, if it fails and main moved, resets the checkout to the new main and runs it again, up to RELEASE_MAX_ATTEMPTS (default 3) times. Version, changelog, image and tag are always made from the same tree: the release commit is never rebased onto commits the image does not contain. The image layers are cached, so a retry is cheaper. A failure while main did not move is a real failure and is returned unchanged. Without the wrapper the next push to main would start a fresh release run anyway (the workflow’s concurrency group keeps only the latest), so a red run of this kind heals itself unless nothing is pushed afterwards.

How the sandbox uses the released image#

bin/pi-sandbox runs <PI_SANDBOX_IMAGE_REPO>:<VERSION> (default repository codeberg.org/black0ut-pi-setup/pi-sandbox) and pulls it on every start, so the local copy always matches the release — the version itself stays pinned by VERSION (new releases arrive with git pull). If the pull fails (offline, no registry login), the wrapper warns and uses the local image, or builds it from the Dockerfile with the same tag if there is none. PI_SANDBOX_PULL=never skips the pull. pi-sandbox rebuild always builds locally; start with PI_SANDBOX_PULL=never afterwards if you want to run that local build. Before the first release VERSION is 0.0.0, so the first start builds locally.

If the package is private, log in once on the Mac: docker login codeberg.org (username + a Codeberg token with read:package).

One-time setup#

1. Self-hosted Forgejo runner#

Run a forgejo-runner in the homelab and register it for the black0ut-pi-setup organisation (Codeberg → organisation → Settings → Actions → Runners → Create new runner) with the label pi.

The runner host runs podman. Jobs run in containers (pi:docker://node:24-bookworm); the runner talks to podman through its Docker-compatible socket and must mount that socket into the jobs. In the runner’s config.yml:

yaml
container:
  docker_host: automount          # mounts the engine socket into jobs at /var/run/docker.sock

and start the runner with DOCKER_HOST=unix:///run/podman/podman.sock (rootful) or unix://$XDG_RUNTIME_DIR/podman/podman.sock (rootless), with podman.socket enabled.

What the jobs install (scripts/ci-install-tools.sh):

JobOptionWhy
check--dockerDocker CLI + compose plugin for docker compose config (no daemon needed)
release--podmanpodman 4.3.1 client; scripts/release-image.sh runs podman --remote build --platform linux/amd64,linux/arm64 --manifest … and podman --remote manifest push --all against the host’s podman (CONTAINER_HOST=unix:///var/run/docker.sock)

Images are built by podman (Buildah’s build engine) on the runner host, through the mounted socket — rootless if the runner uses the user’s podman.socket, rootful with /run/podman/podman.sock. BuildKit is only used by the docker fallback.

For the foreign architecture (arm64 on an amd64 host, or the reverse) the runner host needs QEMU user emulation registered with the kernel; without it the build fails on the first RUN with exec container process '/bin/sh': Exec format error:

bash
sudo apt install qemu-user-static binfmt-support    # Debian/Ubuntu (Fedora: dnf install qemu-user-static)
cat /proc/sys/fs/binfmt_misc/qemu-aarch64           # must exist, "enabled", flags include F
podman run --rm --platform linux/arm64 docker.io/library/debian:bookworm-slim uname -m   # → aarch64

The F flag makes the handler work for rootless podman too. Run the last command as the same user as the runner’s podman.

Runner in a Proxmox LXC container#

An unprivileged LXC container does not see the host’s binfmt handlers (Proxmox kernels ≥ 6.8 give it its own, empty binfmt_misc instance), and systemd’s own proc-sys-fs-binfmt_misc.mount is skipped there because /proc/sys is read-only. Install the QEMU packages inside the runner container as above, then mount binfmt_misc and register the handlers at boot with a oneshot unit:

bash
cat >/etc/systemd/system/binfmt-lxc.service <<'EOF'
[Unit]
Description=Mount binfmt_misc and register QEMU handlers (LXC)
DefaultDependencies=no
After=local-fs.target
Before=podman.socket podman.service sysinit.target
ConditionVirtualization=container

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'mountpoint -q /proc/sys/fs/binfmt_misc || mount -t binfmt_misc binfmt_misc /proc/sys/fs/binfmt_misc'
ExecStart=/usr/lib/systemd/systemd-binfmt

[Install]
WantedBy=sysinit.target
EOF

systemctl daemon-reload
systemctl enable --now binfmt-lxc.service
  • The mount happens only if it is not there yet; systemd-binfmt registers the handlers from /usr/lib/binfmt.d/ (installed by qemu-user-binfmt). ConditionVirtualization=container makes the unit a no-op outside a container.
  • It runs before podman starts, so the first build after boot already has the handlers.
  • Verify after restarting the container (pct reboot <ctid> on the Proxmox host): systemctl status binfmt-lxc.service is active (exited), then the cat and podman run checks above inside the container.
  • Registering the handlers on the Proxmox host as well (same packages) does no harm but is not what the container uses.
  • If the mount is refused (“permission denied”), the container’s AppArmor profile blocks it. Do not make the release runner unconfined or privileged — it holds the release token. Use a VM for the runner, or publish amd64 only (PI_SANDBOX_PLATFORMS=linux/amd64); Apple Silicon Macs then build their image locally through the pi-sandbox fallback.

The podman client in jobs is 4.3.1 (Debian bookworm). It works against a 4.x host; against a 5.x host the remote API is usually compatible, but if podman --remote fails, set PI_SANDBOX_IMAGE_ENGINE=docker and provide buildx instead. A Docker host works too: release-image.sh falls back to docker buildx when podman is not installed.

The runner host is trusted infrastructure: it holds the release token and can push images. Use it only for these repos.

2. Enable Actions and secrets#

In both repos: Settings → Units → enable Actions. Then, at organisation or repo level (Settings → Actions → Secrets):

SecretValue
RELEASE_TOKENCodeberg token of the release account with write:repository (tags, release commit, releases) and write:package (image push)
REGISTRY_USERUsername of that account (sandbox only)

Use a dedicated bot account if main is protected; give it push rights for the release commit or allow it to bypass the protection. Never put these values in the repos.

3. Merge to main#

The first run after merging creates v1.0.0 (there are feat: commits and no earlier tag).

Recovery: a release was tagged but its image is missing#

Run Actions → publish-image → Run workflow with the version (e.g. 1.0.0). It builds from tag v<version> with the current scripts/release-image.sh and pushes codeberg.org/black0ut-pi-setup/pi-sandbox:<version>. If the Codeberg release itself is missing too (its step runs after the image push), create it by hand: Releases → New release → tag v<version>, notes = that version’s section of CHANGELOG.md.

Since the build moved before tagging, this should only be needed when the push fails (registry, token) after a successful build.

Image retention#

Codeberg limits the storage of an owner’s packages; each multi-arch release is roughly 700 MB compressed, so old images must go. Before every image push (release and publish-image), scripts/prune-images.sh --keep 1 keeps the newest existing release tag and the per-arch manifests it references and deletes older release tags and unreferenced digests. After the push, the new version and one previous version remain — enough to roll back via PI_SANDBOX_VERSION. Older versions are rebuilt on demand with publish-image if needed.

Preview what would be deleted (token with package read/write in your password manager):

bash
RELEASE_TOKEN="$(op read …)" REGISTRY_USER=<user> scripts/prune-images.sh --keep 1 --dry-run

If a push already failed with 413 … enforcePackagesQuota, run publish-image for that version: it prunes first, then pushes.

Local dry run#

bash
npm ci --ignore-scripts
npx semantic-release --dry-run --no-ci \
  --repository-url ssh://git@codeberg.org/black0ut-pi-setup/pi-docker-sandbox.git \
  --branches <pushed-branch> \
  --plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator

Unset any GITLAB_TOKEN / GITEA_TOKEN in your shell first, or semantic-release will try an HTTPS remote with it.