RigForge follows Semantic Versioning. The current version is
tracked in VERSION and the history in CHANGELOG.md.
- MAJOR: incompatible
config.json/ CLI / behavior changes. - MINOR: new, backwards-compatible functionality.
- PATCH: backwards-compatible fixes.
From 1.0.0 on, the config.json and CLI surface is stable, so a breaking change bumps MAJOR. (Pre-1.0
0.x releases could break the interface between minor versions while it settled.)
Work lands on develop (the integration branch); a release is the point where develop is
promoted to main and tagged. The steps below build the release commit on develop, merge it to
main, and tag from main.
-
Ensure
developis green:make test(andmake test-e2eif Docker is available). -
Full real-hardware e2e (the release gate). CI exercises everything it can (lint, the dependency-free suite, the Docker
/etce2e, the coverage gate), but it can't compile XMRig, reserve HugePages, write MSRs, set the governor, or actually hash. So on a real Linux rig, run the genuine deploy end to end and assert each step:sudo bash tests/e2e-real.sh provision # real deps + XMRig build + tuning + kernel tuning + service sudo reboot # HugePages (1G + GRUB cmdline) take effect on boot; reconnect sudo bash tests/e2e-real.sh verify # doctor (HugePages/MSR/governor/service) + bench (real H/s) + a short tune + a live auto-tune pass sudo bash tests/e2e-real.sh control # the writable control path (#236) against real systemd: enable, POST a change, poll to applied, revert sudo bash tests/e2e-real.sh upgrade # the remote-upgrade chain (#308/#322) with REAL git: noop + refused-tag rollback legs, plus a mandatory forward leg that auto-derives the previous real release tag -> current and proves it, then reverts (skip with a reason: E2E_UPGRADE_SKIP_REASON="...") sudo bash tests/e2e-real.sh watchdog # the thermal-hold path (#349) against the real sensor and real systemd: lowers max_temp_c below the live reading, runs the verb once, asserts the stop + hold marker + journal evidence, restores on every exit path (skips explicitly when no temperature reading is available) sudo bash tests/e2e-real.sh perf # offline bench vs the committed per-host baseline + best-ever history (the release perf gate) sudo bash tests/e2e-real.sh teardown # uninstall + assert a clean revert
When a live Pithead stack is reachable, also run the worker↔stack contract gate (stack on its latest release tag — record
pithead versionin the run log). It asserts the mining round-trip, the:8080API contract, stratum auth (passE2E_STRATUM_PASSif the stack uses one), dashboard visibility (E2E_DASH_URL), and that the sister API does not shave hashrate under polling load:PITHEAD_URL=<stack-host>:3333 sudo -E make e2e-pithead
Both gates carry the standardized performance checks (see
tests/README.md› Performance testing):e2e-real'sperfphase compares the offline bench against the committed per-host baseline intests/perf-baselines/, ande2e-pithead'sapi-impactphase proves the sister API doesn't shave live hashrate. A perf regression fails the gate — investigate or consciously re-record the baseline before tagging.Each phase must report
E2E-REAL (<phase>): PASS. This proves a release bundle actually builds, tunes, and hashes on real hardware, which the suites can't since they all stub XMRig.- Put a real, reachable pool in
config.jsonfirst. Without one,setupwrites an unroutable placeholder andverifyfails the connect + share-submission round-trip. That round-trip is mandatory, since proving the rig really mines is the whole point of the gate. Pointpools[0].urlat a real low-difficulty pool you control (e.g. the stack's test pool). For a deliberate offline smoke run with no pool on hand, setE2E_ALLOW_OFFLINE_POOL=1to downgrade it to an explicit skip. - Quick subset:
make smoke(bench-only) is the fast version when you just need to confirm a built worker still hashes; the fulle2e-realflow above supersedes it for a real release. - Kept out of CI on purpose (a real build + HugePages + mining are flaky by nature and against Actions' ToS); it's a manual pre-tag gate the releaser runs.
- Put a real, reachable pool in
-
In
CHANGELOG.md, move the## [Unreleased]entries under a new## [X.Y.Z] - YYYY-MM-DDheading, then leave a fresh empty## [Unreleased]above it. -
Bump
VERSIONtoX.Y.Z, and move thegit clone --branch vX.Y.Zpin inREADME.mdanddocs/getting-started.mdto the same tag (the suite fails on an unpinned clone or a pin newer thanVERSION). -
Commit the two together on
develop:git commit -am "release: vX.Y.Z" git push origin develop -
Promote
developtomain. Open a pull request first — it carries the review, the CI run and the audit trail for the promotion, andmain's ruleset requires one:gh pr create --base main --head develop --title "release: vX.Y.Z" \ --body "Promote develop to main for the vX.Y.Z release."
Before review, record the PR's exact head in a private local ref. Set
RELEASE_PRto the number printed bygh pr create, then give the printed sha to the reviewer. Their verdict must name that same sha:: "${RELEASE_PR:?set RELEASE_PR to the promotion PR number}" repo=$(gh api repos/{owner}/{repo} --jq .full_name) reviewed_commit=$(gh api "repos/$repo/pulls/$RELEASE_PR" --jq .head.sha) git update-ref refs/rigforge/release-candidate "$reviewed_commit" \ || { echo "cannot record the reviewed commit — refusing to continue"; exit 1; } printf 'Review release candidate %s\n' "$reviewed_commit"
After a PASS at that sha, land it with a fast-forward push rather than the merge button, so
mainends up on the reviewed release commit exactly — same sha, not merely the same tree — and stays linear. GitHub closes the PR as merged once its commits are reachable frommain:release_commit=$(git rev-parse refs/rigforge/release-candidate) git fetch origin \ || { echo "fetch failed — refusing to promote cached refs"; exit 1; } test "$(git rev-parse refs/remotes/origin/develop)" = "$release_commit" \ || { echo "develop moved after review — stop and review the new commit"; exit 1; } main_commit=$(git rev-parse refs/remotes/origin/main) git merge-base --is-ancestor "$main_commit" "$release_commit" \ || { echo "NOT a fast-forward — main has commits develop lacks; back-merge first (see below)"; exit 1; } git push origin "$release_commit:refs/heads/main" test "$(git ls-remote --heads origin refs/heads/main | cut -f1)" = "$release_commit" \ || { echo "main does not point at the audited release commit"; exit 1; }
The recorded sha closes the review-to-fetch race. The explicit push closes a second trap: a refspec source such as
develop:mainresolves the unqualifieddeveloplocally, so it can push a stale local branch even when every preflight readorigin/develop. The commands above bind review, gate, push, and readback to one object.The
Main Branchruleset targetsrefs/heads/mainonly (developcarries no rules at all) and haspull_request,non_fast_forwardanddeletion, withOrganizationAdminbypass atbypass_mode: always. The push satisfiesnon_fast_forward— that rule blocks force-pushes, and this is a genuine fast-forward — and needs the bypass forpull_request. The organization-admin bypass admitted both corrective fast-forward pushes during v1.17.0. If a future push is refused, fall back togh pr merge --merge --adminand then back-merge (git merge origin/mainondevelop) to restore the invariant before the next release.The invariant is the point:
mainmust stay an ancestor ofdevelop. Bothgh pr mergemodes break it, in different ways, and the repo has been broken by each in turn.--mergewrites a merge commit ontomainthatdevelopnever receives. That is how the last divergence started:23fcd27("release: v1.12.0 (promote develop to main via merge)", 2026-07-19) has two parents, and its second parent3220f57is the last commit the two branches shared. Nothing back-merged it, so they never re-converged.--rebaseis worse: it rebases develop's commits ontomain, minting new shas, somainends up carrying twins of commitsdevelopstill holds under their original shas. Later promotion PRs then come backCONFLICTINGand need a hand-built reconcile commit —cfd92fa(v1.15.0) and60aa883(v1.15.1) are two of those, and PR #368 is a promotion that could not be merged at all.Five releases were cut while diverged (v1.13.0, v1.13.1, v1.14.0, v1.15.0, v1.15.1), drifting to 37 commits on
mainthatdeveloplacked, untilde4e781healed it. In that whole window no tag ever sat on develop's release commit — every one of v1.12.0…v1.15.1 is unreachable from develop as it stood before the heal. A fast-forward is what puts them back on the same commit.Verify with
git merge-base --is-ancestor origin/main origin/developbefore promoting. If a hotfix ever lands directly onmain, back-merge it (git merge origin/mainondevelop) to restore the invariant before the next release. -
Tag and push from
main(annotated tag, matchingVERSION) once the PR is merged:release_commit=$(git rev-parse refs/rigforge/release-candidate) git checkout main \ || { echo "cannot check out main — refusing to tag"; exit 1; } git pull --ff-only origin main \ || { echo "pull failed — refusing to tag a cached main"; exit 1; } test "$(git rev-parse HEAD)" = "$release_commit" \ || { echo "main moved after review — stop and review the new commit"; exit 1; } git tag -a vX.Y.Z "$release_commit" -m "RigForge vX.Y.Z" \ || { echo "tag creation failed — refusing to push an existing local tag"; exit 1; } git push origin refs/tags/vX.Y.Z test "$(git ls-remote --tags origin 'refs/tags/vX.Y.Z^{}' | cut -f1)" = "$release_commit" \ || { echo "the remote tag does not dereference to the release commit"; exit 1; } git update-ref -d refs/rigforge/release-candidate
The private local ref carries the reviewed commit across release steps and makes a concurrent
mainchange fail closed. Push the named tag explicitly:--follow-tagscan truthfully report that the branch is current without proving the intended tag moved, so the remote dereference is the release evidence.
Pushing the tag triggers the release pipeline
(.github/workflows/release.yml), which:
- verifies the tag matches
VERSION(the build fails otherwise), - packages the deploy bundle (
rigforge.sh,util/,systemd/,config.minimal.json,config.reference.json,README.md,docs/,images/,LICENSE,VERSION) asrigforge-vX.Y.Z.zipand.tar.gz(tests/,.github/, and other dev files are excluded), - generates
SHA256SUMSfor the artifacts, - pulls that version's section from
CHANGELOG.mdas the release notes, - creates the GitHub Release as a draft. Review the generated notes and bundles, then click
Publish (pre-1.0
0.xtags are marked pre-release;1.0.0+ are full releases).
For a full release, make the published release the repository's latest marker explicitly and read
it back. Rigs' upgrade --check and remote upgrade path follow that endpoint; “published” and
“latest” are separate release state:
repo=$(gh repo view --json nameWithOwner --jq .nameWithOwner)
release_id=$(gh api "repos/$repo/releases/tags/vX.Y.Z" --jq .id)
gh api -X PATCH "repos/$repo/releases/$release_id" -f make_latest=true
test "$(gh api "repos/$repo/releases/latest" --jq .tag_name)" = vX.Y.Z \
|| { echo "published release is not the repository's latest marker"; exit 1; }The Release latest marker workflow repeats that read after every non-prerelease publish and fails
loudly if the marker differs. It is a guard, not a substitute for the explicit publish step above.
After a rig is re-tagged, record its benchmark for the release
(E2E_PERF_TAG=vX.Y.Z E2E_PERF_RECORD=1 sudo bash tests/e2e-real.sh perf on the rig) and commit
the updated tests/perf-baselines/ files — the per-release history is what lets the perf gate
catch slow drift across releases (see tests/perf-baselines/README.md). It is whichever rig ran the
gate, which is not always miner-0 despite tests/README.md
calling it the shared rig: v1.15.0 was gated on miner-2 and v1.15.1 on miner-3, and miner-0 currently
cannot pass the gate at all — it dual-boots Windows, so Secure Boot is enabled, kernel lockdown
(integrity) denies every MSR write, and doctor counts that as an issue and exits non-zero. Pick a
rig with Secure Boot off. The rest of the fleet isn't re-tagged on every release, so its baselines are
only as fresh as the last time each rig was actually touched. tests/perf-baselines/ legitimately
carries gaps between releases for rigs that went untouched — it is not a promise that every rig has
an entry for every tag. The recording is also the per-rig perf gate (#214): it judges against the
committed baseline and best-ever history before writing, refuses to record a regressed number (fix
it, or consciously override with E2E_PERF_FORCE=1), so a failed rig means investigate before
calling it healthy. Once a rig's baseline is merged, reset its copy
(sudo git checkout -- tests/perf-baselines/ in /opt/rigforge): the recording dirties the rig's
checkout, and the next release's
git checkout <tag> aborts on exactly those files (this bit both the v1.4.0 and v1.5.0 deploys).
To verify a downloaded bundle: sha256sum -c SHA256SUMS (see
SECURITY.md › Release integrity).
The release is created as a draft so a human reviews it before it goes public, a deliberate gate for a tool that installs a root miner. Drop
--draftfromrelease.ymlto auto-publish on tag instead.
- Keep
VERSIONand the latestCHANGELOG.mdheading in lock-step; the test suite checksVERSIONis valid SemVer. VERSIONis also surfaced at runtime:rigforge.sh version(or--version/-v) reads it, so a release tag, the changelog heading, and what the script reports all stay in agreement.