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).
| Repo | Released as | Artifacts |
|---|---|---|
pi-docker-sandbox | v<version> tag + Codeberg release | CHANGELOG.md, VERSION, image codeberg.org/black0ut-pi-setup/pi-sandbox:<version> (linux/amd64 + linux/arm64) |
pi-config | v<version> tag + Codeberg release | CHANGELOG.md |
How a version is chosen#
From the Conventional Commits since the last tag (conventionalcommits preset):
| Commit | Release |
|---|---|
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)#
- check — installs the pinned check tools (
scripts/ci-install-tools.sh: bats, jq, shellcheck from apt;justandgitleaksat pinned versions with SHA-256 verification, kept current by Renovate — dependencies.md) and runsjust check. No release if it fails. - release —
npm ci(tooling pinned inpackage.json/package-lock.json), registry login, thennpx semantic-release(.releaserc.json):- analyse commits, generate notes, update
CHANGELOG.md; - sandbox only: write the version to
VERSIONand 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(andVERSION) and push thev<version>tag; - sandbox only: push the image (
scripts/release-image.sh push <version>); - create the Codeberg release (
@saithodev/semantic-release-gitea, Forgejo-compatible).
- analyse commits, generate notes, update
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:
container:
docker_host: automount # mounts the engine socket into jobs at /var/run/docker.sockand 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):
| Job | Option | Why |
|---|---|---|
check | --docker | Docker CLI + compose plugin for docker compose config (no daemon needed) |
release | --podman | podman 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:
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 # → aarch64The 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:
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-binfmtregisters the handlers from/usr/lib/binfmt.d/(installed byqemu-user-binfmt).ConditionVirtualization=containermakes 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.serviceisactive (exited), then thecatandpodman runchecks 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 thepi-sandboxfallback.
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):
| Secret | Value |
|---|---|
RELEASE_TOKEN | Codeberg token of the release account with write:repository (tags, release commit, releases) and write:package (image push) |
REGISTRY_USER | Username 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):
RELEASE_TOKEN="$(op read …)" REGISTRY_USER=<user> scripts/prune-images.sh --keep 1 --dry-runIf a push already failed with 413 … enforcePackagesQuota, run publish-image for that version:
it prunes first, then pushes.
Local dry run#
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-generatorUnset any GITLAB_TOKEN / GITEA_TOKEN in your shell first, or semantic-release will try an
HTTPS remote with it.