How Pithead is versioned and released
(#44). The pipeline is
implemented as scripts/release.sh. Run it from the build/test
server with make release (preview a run with make release ARGS="--dry-run").
Pithead is versioned and released as a single product, not as individual components.
The components are upstream projects pinned and integrated, not authored here: p2pool
(ARG P2POOL_VERSION), xmrig-proxy (ARG XMRIG_PROXY_VERSION), monerod
(ARG MONERO_VERSION), and tari (quay.io/tarilabs/minotari_node:v5.3.1-mainnet,
pinned by digest in docker-compose.yml). The first-party code is the dashboard plus the
orchestration (pithead, docker-compose.yml, configs). The integration matrix validates the
composed set. A release is one artifact with one version, one changelog, one upgrade path, and
one "new version available" signal.
Trade-off: swapping a single component to a version not tested together is unsupported. You can override an ARG or image tag locally, but that combination is not a supported release.
The product version lives in a top-level VERSION file: plain text, one line,
SemVer. Nothing else hardcodes the version:
pitheadreads it for tagging and upgrade.- The dashboard bakes it in for display (#58).
- Released images carry it in the
org.opencontainers.image.versionOCI label. - The dashboard's
pyproject.tomlis kept in lockstep (packaging metadata only); a shell test fails if it drifts fromVERSION.
NOTE:
VERSIONholds the last released version. Set it to the version you want to publish; thepyproject.tomlmetadata must match, enforced by the drift-guard test.
Every component stays pinned (build ARGs and image tags). The pins are the ingredients lockfile of each product release, not independent releases:
- The dashboard surfaces them as "what's inside vX.Y.Z" (component info is already shown).
- Bumping any component, including a security patch such as a
monerodCVE, is a normal stack release: bump the pin → cut a stack patch → re-run the integration gate → ship. The bundle ships re-tested.
Images are published to GitHub Container Registry (ghcr.io/p2pool-starter-stack/*). Public
images pull without a Docker Hub-style rate limit.
The stack is multi-container. Every built image (pithead-dashboard, pithead-p2pool,
pithead-xmrig-proxy, pithead-monero, pithead-tor) is published, all tagged with the single
stack version, and compose references one ${STACK_VERSION}. docker compose pull fetches the
set with one knob, replacing the "git pull + rebuild" upgrade path. The version is the bundle,
not the layers.
Releases are cut on a private build/test server that runs the full Monero and full Tari nodes
(the integration-test environment from
#54). A single entry point,
make release (or pithead release), runs the pipeline. Nothing is promoted or published until
every gate is green.
How to provision and harden that server, why end-to-end validation can't run on GitHub-hosted runners (and what does run free on every PR), and the safe self-hosted-runner setup are covered in Release / Validation Server.
- Preflight: clean working tree; read the product version from the top-level
VERSIONfile; confirmvX.Y.Zisn't already released; resolve the component pins into the ingredients manifest. - Test gate (blocking): run the existing tests (
make test: lint + dashboard pytest ≥ 80% + thepitheadshell suite + compose validation) and the #54 integration matrix against the real nodes. Abort the release on any failure. See Pre-release gate. - Build: build the first-party images with the pinned upstream versions baked in and OCI labels
stamped (
org.opencontainers.image.version= theVERSIONvalue, source revision, etc.). The dashboard already readsPITHEAD_VERSION/ git build-args for its header badge (#58); a release build must passPITHEAD_RELEASE=1(andPITHEAD_VERSIONfromVERSION) so the badge shows the cleanvX.Y.Zrather than thedev · branch @ hashit shows for working-tree builds. - Push to staging: push to a staging tag on GHCR (e.g.
:vX.Y.Z-rc.N) and capture the immutable digests. Nothing user-facing points here yet. - Staging smoke test (gate): on a clean host, pull the staged images from GHCR and run the real
user path (
setup→ up →status, minimal mine check). This proves the actually-pushed artifacts work, not just the local build. Abort on failure. - Promote by digest: re-tag the exact digests just smoke-tested to
:vX.Y.Zand:latest, then push. Promotion is by digest (no rebuild), so the released bundle is bit-for-bit what was validated. Same version on every image. - Sign (#376): cosign-sign each promoted manifest-list digest and the install bundle with the key on the release server. See Signed releases.
- Publish GitHub Release: create the git tag
vX.Y.Z, write theCHANGELOG.mdentry / release notes, and attach release assets: a pinneddocker-compose.yml/ config bundle referencing${STACK_VERSION}=vX.Y.Z, its detached signature (pithead.tar.gz.sig), plus the ingredients manifest (exact component versions + promoted image digests). - Post-publish smoke (#459): run
make release-smokeonce against the just-published tag. It downloads the published bundle + images and verifies them for real, and — on the previous-release bench box — drives the real #59 upgrade. See Post-publish smoke test. This is the only step gated behind the publish, because it checks the published artifact, not a branch.
The #54 integration test matrix is a required, blocking pre-release gate. A release must not be promoted or published unless that matrix is green against the real Monero + Tari nodes. This is what makes every published version a single, validated bundle.
Two runs of the matrix are required, and the automated one is the smaller of the two:
-
release.shstage 2 runs the non-destructive--readinessassessment against the live stack (viaRELEASE_INTEGRATION_ARGS). It proves the box is fit to cut from — it does not mine, restart anything, or touch a rig. -
Before cutting, run the targeted end-to-end matrix on the release candidate with a borrowed loaner rig:
BENCH_HOST=<bench> MINER_HOST=<loaner-rig> tests/integration/e2e.sh <ref> --mode targeted
This deploys the candidate to the bench's dedicated e2e checkout, repoints the rig at it (under the rig lock, with automatic restore of both), and proves what
--readinesscannot: a real miner mining through the stack, the lifecycle phase (restart, apply secret-preservation, node-down failover), and fail-closed auth. If the release's diff touches the worker or control-descriptor path, add the--rigforge-controllegs (needs a rig with its control API enabled). The readiness gate alone does not satisfy this requirement; abort the release on any failure.
After deploying the published release to the bench, run the non-destructive live sweep as the
closing check: tests/integration/run.sh --local --dir <stack-dir> --check. On a bench with no
miners connected, exactly two failures are expected — workers online and stratum total hashes — anything else is a regression.
Every promoted image digest and the install bundle carry a cosign key signature
(#376). The private key lives only
on the release server (COSIGN_KEY / COSIGN_PASSWORD, provisioned once — see
Release / Validation Server › The release signing key);
the public key is committed at the repo root as cosign.pub and ships inside
every release bundle, next to pithead. Signing is key-based, not Sigstore keyless: releases are
cut from a private box with no CI OIDC identity, and --tlog-upload=false keeps release activity
out of the public transparency log — which is why verification passes --private-infrastructure
(images) / --insecure-ignore-tlog (the bundle blob). Signatures pin the promoted manifest-list
digests — the exact bytes the smoke stage validated — never a mutable tag.
pithead verifies before it changes anything:
pithead upgradeon a release install withcosign.pubpresent verifies all five first-party images (cosign verify --key cosign.pub --private-infrastructure) and aborts on the first failure. Nothing is pulled or restarted.- The dashboard upgrade (#59)
additionally fetches
pithead.tar.gz.sigand verifies the downloaded bundle against the key already on disk before extracting a byte. The new bundle's owncosign.pubis never the trust anchor for its own verification, so a malicious bundle cannot vouch for itself; a key rotation reaches installs through a bundle signed with the previous key. - Verification fails closed. With
cosign.pubpresent, a bad signature, a strippedpithead.tar.gz.sig, or a missingcosignbinary each abort the upgrade with a message naming the fix. Only an install with nocosign.pubat all — older than the first signed release — proceeds unverified, with a warning saying exactly that;pithead doctorreports the state. - Source checkouts skip verification: locally built images are unsigned by design.
What the signature does and does not prove: it proves the artifact was produced by the holder of the release key, so a re-pointed GHCR tag, a tampered registry, or a swapped GitHub release asset fails verification. It cannot protect against a compromise of the release server itself, which holds the key and cuts the releases.
pithead upgrade runs the checks automatically once cosign is installed
(pinned install: release-server.md — the same
snippet works on any host). To verify by hand against the committed
cosign.pub:
# An image (repeat per image, or pin the digest from the ingredients manifest):
cosign verify --key cosign.pub --private-infrastructure \
ghcr.io/p2pool-starter-stack/pithead-dashboard:vX.Y.Z
# The install bundle:
curl -fsSLO https://github.com/p2pool-starter-stack/pithead/releases/download/vX.Y.Z/pithead.tar.gz.sig
cosign verify-blob --key cosign.pub --signature pithead.tar.gz.sig --insecure-ignore-tlog=true pithead.tar.gzReleases up to v1.3.x are unsigned; verification gates every release from the first signed one.
Two features can't be tested before merge, because both need a published release to exist: the
#376 cosign verification and the
#59 one-click upgrade. The tier-4 matrix
tests a branch, not a published artifact, so the pre-merge tests only ever see a fake cosign. Right
after make release publishes, run scripts/release-smoke.sh once to
verify the real thing:
make release-smoke # verify the just-published version
make release-smoke ARGS="--upgrade /srv/code/previous" # + drive the real #59 upgradeIt runs two phases:
- Real cosign verify. Downloads the published
pithead.tar.gz(+.sig) and the five:vX.Y.Zimages and verifies them against the committedcosign.pub. A good signature must pass; a byte-changed bundle must be refused (this is what proves the check is real, not the pre-merge fake); the bundle's ownVERSIONmust equal the tag (the #376 rollback guard); and an unrelated key must be refused. If the release is unsigned — signing is opt-in (#376), so releases up to v1.3.x and any cut with the key off ship without a signature — that is reported plainly and the phase is skipped. It never reports a signed pass for an unsigned release. This phase needs onlyghauth and network; run it anywhere. - Real #59 upgrade (
--upgrade DIR). On a box still running the previous release, it enqueues the exact upgrade intent the dashboard writes into the #33 control spool, runs the host control runner, and asserts the install upgrades cleanly to the published tag over the real download → verify → rollback-guard → extract →pithead upgradepath. This is destructive to that box's stack: run it on the previous-release bench box (stack up,dashboard.controlenabled), never on the release host. The one leg it doesn't drive is the browser → dashboard hop (the dashboard container writing the intent), which is unit-covered (#33/#59) and confirmed by clicking the button once by hand.
- Versioning: Semantic Versioning (
vMAJOR.MINOR.PATCH), single source of truth in the top-levelVERSIONfile.pitheadreads it for tagging/upgrade; the dashboard reads it for display (#58). - Changelog: hand-curated
CHANGELOG.mdfollowing Keep a Changelog + SemVer, with GitHub's auto-generated PR list as a supplement in the release body. (Chosen over Conventional-Commits / release-please automation because the commit style here is human, issue-referencing summaries; curated notes read better for users, and automation can come later if a commit convention is adopted.) - Patches: security and component bumps ship as normal patch releases, re-validated through the same gate.
- Install: download the release's compose bundle (or
git checkout vX.Y.Z) →./pithead setup. Images are pulled from GHCR; no local build. - Upgrade: the dashboard shows "vX.Y.Z available"
(#59) → run
./pithead upgrade→ it pulls the new single-tagged images and recreates only what changed. - Every published version is one immutable, #54-validated bundle; release notes list what's inside and what changed.
What exists today:
-
✅ Top-level
VERSIONfile (single source of truth). -
✅
CHANGELOG.md(Keep a Changelog + SemVer, with anUnreleasedsection). -
✅ This document.
-
✅ The #54 integration test suite: the live config-matrix gate against real nodes (
tests/integration/,make test-integration). See Integration Testing. -
✅ The dashboard version badge (#58):
VERSION+ git build-args baked into the dashboard image (env + OCI labels); showsvX.Y.Zon releases anddev · branch @ hashotherwise. -
✅ The release pipeline,
scripts/release.sh(make release). Implements the full preflight → test gate (make test+ the #54 matrix, blocking) → build (OCI labels +PITHEAD_RELEASE=1) → stage to:vX.Y.Z-rc.N→ smoke-verify the pushed images → promote-by-digest to:vX.Y.Z+:latest→ publish the GitHub Release. It generates the ingredients manifest (promoted digests + upstream pins) and a pinned install bundle as release assets, never starts the live stack on the build host, and never prints the registry token. Preview any run withmake release ARGS="--dry-run". -
✅ Signed releases (#376): the pipeline cosign-signs the promoted digests + the bundle,
pithead upgradeand the dashboard upgrade verify against the committedcosign.puband fail closed. See Signed releases. -
✅ Pull-based install:
${STACK_VERSION}wired throughdocker-compose.yml. Each first-party service now carries animage: ${PITHEAD_REGISTRY:-…}/pithead-<svc>:${STACK_VERSION:-dev}ref alongside itsbuild:. pithead picks build-vs-pull automatically: a source checkout (the image Dockerfiles are present) builds locally and tags:devwith--pull never; a release install (the bundle ships no Dockerfiles, justpithead+VERSION+ compose + the config templates + the./buildruntime mounts) resolvesSTACK_VERSIONtovX.Y.Zand pulls the published images (--pull missing;upgradeforces a re-pull). Override withPITHEAD_REGISTRY/PITHEAD_PULL. So a release is nowcp config.minimal.json config.json && ./pithead setup, no local build.
Remaining:
- ⬜ The dashboard "new version available" update warning (#59), which builds on the version badge. Tracked separately.
NOTE: before the first real release, choose the first published version. Set
VERSION(thepyproject.tomlmetadata follows it, enforced by the drift-guard test) and confirm the GHCR image namespace (scripts/release.shdefaults toghcr.io/p2pool-starter-stack/pithead-*; override withPITHEAD_REGISTRY/PITHEAD_IMAGE_PREFIX).