2026-07-10 20:56:05 +00:00
#!/usr/bin/env bash
set -euo pipefail
2026-07-11 08:25:48 +00:00
# rig installer — intended for: curl -fsSL .../install.sh | bash
2026-07-10 20:56:05 +00:00
#
2026-07-18 20:57:13 +00:00
# Three channels from this one script (heavy-duty/rig#32; box#83's design):
#
# RIG_REF unset the latest RELEASE — the tag is resolved from the
# releases/latest redirect, the download is that tag's
# source tarball (which IS the package)
# RIG_REF=<tag> that release, pinned (a tag outranks a branch of the
# same name)
# RIG_REF=<branch> the development tree, e.g. RIG_REF=main
#
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# Downloads the rig repo tarball and installs it into the VERSIONED layout
# under $DEST (box#79's layout, ported — heavy-duty/rig#35):
#
# $DEST/versions/<version>/ one full tree per installed version
# $DEST/current -> versions/<version> the default version
# $BINDIR/rig -> $DEST/current/bin/rig the PATH entry
#
# Versions install side by side: `rig versions` lists them, `rig use <v>`
# switches the default, `rig uninstall` removes them. Re-running with an
# already-installed version is a converging no-op (RIG_REINSTALL=1 replaces
# that version's tree); a NEW version installs beside the old one and becomes
# the default — with a WARNING when this host is bootstrapped (/etc/rig/role
# exists), because switching the rig under a converged host changes what a
# re-converge would do. rig warns where box refuses: rig has no boxes to
# protect, and the operator flipping versions on purpose is the common case.
# A pre-versioning flat tree is migrated in place, so upgrading is seamless.
#
2026-07-24 01:43:15 +00:00
# RIG_INSTALL_SOURCE=<dir-or-tarball> is the LOCAL channel, a supported input
# like RIG_REF (#106): installs from that tree instead of downloading — CI's
# install-lifecycle job and the test suites use it, so what lands is the code
# under review. A path that is neither refuses by name, never falls back to
# a download.
2026-07-10 20:56:05 +00:00
2026-07-11 08:25:48 +00:00
REPO = " ${ RIG_REPO :- heavy -duty/rig } "
2026-07-18 20:57:13 +00:00
REF = " ${ RIG_REF :- } " # empty = the latest release, resolved below
2026-07-29 14:33:38 +00:00
# The forge this REPO lives on is RIG_HOST (#111), default https://github.com.
# Parallel to RIG_TEMPLATES_HOST — not the same variable, because the registry
# and rig itself may live on different forges. Default stays GitHub so every
# existing curl|bash one-liner is byte-unchanged; set
# RIG_HOST=https://forgejo.heavyduty.builders to install from this instance.
2026-07-18 22:36:05 +00:00
# cloud-init's runcmd runs with NO $HOME in the environment, and under set -u
# the expansions just below turned that into a death instead of an install —
# found live by box#88's seed, which pins HOME=/root as its own scar (rig#39).
# Derive it from the effective user instead: getent knows every user's home,
# root included. (Inline error: die() is not defined this early on purpose —
# this guard must run before any path is derived from $HOME.)
if [ -z " ${ HOME :- } " ] ; then
# '|| true': under pipefail a no-answer getent would kill the script here
# with ITS exit code, instead of falling through to the named refusal.
HOME = " $( getent passwd " $( id -u) " | cut -d: -f6 || true ) " ; export HOME
if [ -z " $HOME " ] ; then
printf "rig-install: ERROR: \$HOME is unset and getent knows no home for uid %s — set HOME and re-run\n" " $( id -u) " >& 2
exit 1
fi
fi
2026-07-11 08:25:48 +00:00
DEST = " ${ RIG_HOME :- $HOME /.local/share/rig } "
2026-07-10 20:56:05 +00:00
if [ " $( id -u) " -eq 0 ] ; then
2026-07-11 08:25:48 +00:00
BINDIR = " ${ RIG_BIN :- /usr/local/bin } "
2026-07-10 20:56:05 +00:00
else
2026-07-11 08:25:48 +00:00
BINDIR = " ${ RIG_BIN :- $HOME /.local/bin } "
2026-07-10 20:56:05 +00:00
fi
2026-07-11 08:25:48 +00:00
log( ) { printf 'rig-install: %s\n' " $* " ; }
warn( ) { printf 'rig-install: WARNING: %s\n' " $* " >& 2; }
die( ) { printf 'rig-install: ERROR: %s\n' " $* " >& 2; exit 1; }
2026-07-10 20:56:05 +00:00
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# A version is a DIRECTORY NAME under versions/ — nothing else. One strict
# gate for every caller that builds a path from one (the installer's new_ver,
# migration's flat_ver, and bin/rig's 'use'/single-version uninstall): only
# [A-Za-z0-9._+-], no leading '.' or '-'. That forbids '/', '..'-escapes,
# spaces and option-lookalikes by construction — a crafted version dies HERE,
# never in an rm -rf or an ln. bin/rig carries a byte-identical copy;
# test/cli.sh diffs the two so the gates cannot drift.
valid_version( ) {
case " $1 " in
'' | .*| -*) return 1 ; ;
*[ !A-Za-z0-9._+-] *) return 1 ; ;
esac
return 0
}
# The flip gate, rig's shape (#35): box refuses version flips under existing
# boxes; rig's stake is the converged HOST — /etc/rig/role marks a box that
# bootstrap has made into something. Switching the default rig under it
# changes what a re-converge would do, which is worth a warning, not a
# refusal: there is no user state a flip can strand, and flipping versions on
# a bootstrapped host is the normal upgrade. RIG_ROLE_MARKER overrides the
# path so tests point it at fixtures (repo precedent: the coolify marker
# gate). bin/rig carries a byte-identical copy; test/cli.sh diffs the two.
warn_bootstrapped( ) { # $1 = what is about to happen
local marker = " ${ RIG_ROLE_MARKER :- /etc/rig/role } "
[ -e " $marker " ] || return 0
warn " this host is bootstrapped ( $( head -n1 " $marker " 2>/dev/null || echo " role marker at $marker " ) ) "
warn " $1 changes what a re-converge (rig bootstrap, users apply) would do — proceeding. "
}
2026-07-18 20:57:13 +00:00
# --- the release channels (#32; box#83's design, near-verbatim) --------------
# resolve_latest_tag <owner/repo> — print the latest RELEASE tag, resolved by
# following the releases/latest redirect and reading the Location header
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
# rate-limit pain. A repo with no releases redirects to /releases — not to
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
2026-07-29 14:33:38 +00:00
# and the CALLER owns the loud story. Host comes from RIG_HOST (default
# GitHub); both GitHub and Forgejo serve the same /releases/latest →
# /releases/tag/<tag> redirect grammar, measured 2026-07-29 (#111).
# test/release.sh extracts this function (awk, the valid_version idiom) and
# drives it against a stubbed curl.
2026-07-18 20:57:13 +00:00
resolve_latest_tag( ) {
2026-07-29 14:33:38 +00:00
# Default is inlined (not $RIG_HOST_DEFAULT) so test/release.sh's awk
# extract of this function stays self-contained — same discipline as
# valid_version.
local host = " ${ RIG_HOST :- https : //github.com } " loc
host = " ${ host %/ } "
loc = " $( curl -fsSI -o /dev/null -w '%{redirect_url}' " $host / $1 /releases/latest " ) " || return 1
2026-07-18 20:57:13 +00:00
case " $loc " in
*/releases/tag/?*) printf '%s\n' " ${ loc ##*/releases/tag/ } " ; ;
*) return 1 ; ;
esac
}
# ref_candidate_urls <owner/repo> <ref> — the download candidates for an
2026-07-29 14:33:38 +00:00
# explicit RIG_REF, in order. Host comes from RIG_HOST; the two forges are
# not URL-compatible (#111 / #109):
# GitHub refs/tags first so a tag always outranks a same-named branch
# (the pin must win), then refs/heads for RIG_REF=main.
# Forgejo one form: /archive/<ref>.tar.gz resolves tags, branches and
# SHAs alike (same grammar as templates_archive_urls).
2026-07-18 20:57:13 +00:00
ref_candidate_urls( ) {
2026-07-29 14:33:38 +00:00
local host = " ${ RIG_HOST :- https : //github.com } "
host = " ${ host %/ } "
case " $host " in
https://github.com| http://github.com| *//github.com)
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' " $host " " $1 " " $2 "
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' " $host " " $1 " " $2 " ; ;
*)
printf '%s/%s/archive/%s.tar.gz\n' " $host " " $1 " " $2 " ; ;
esac
}
# install_script_url — the curl|bash entrypoint URL for this REPO on RIG_HOST.
# GitHub serves raw files at raw.githubusercontent.com; Forgejo at
# /raw/branch/<ref>/<path>. The refusal hint and bin/rig usage() both print
# this, so a Forgejo install never tells the operator to hit a 404 (#111).
# REPO is the installer's global (RIG_REPO); tests that extract this function
# must set it.
install_script_url( ) {
local host = " ${ RIG_HOST :- https : //github.com } "
host = " ${ host %/ } "
case " $host " in
https://github.com| http://github.com| *//github.com)
printf 'https://raw.githubusercontent.com/%s/main/install.sh\n' " ${ REPO :- heavy -duty/rig } " ; ;
*)
printf '%s/%s/raw/branch/main/install.sh\n' " $host " " ${ REPO :- heavy -duty/rig } " ; ;
esac
2026-07-18 20:57:13 +00:00
}
feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner family
rig's CI story was GitHub-shaped end to end. This makes it work against a
self-hosted Forgejo, in three pieces.
The registry fetch becomes forge-aware. templates_resolve hardcoded three
github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because
the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare
paths, Forgejo serves exactly one, and emitting the other two there would mean
two guaranteed 404s per fetch and a failure message listing URLs that never
could have worked. Measured against forgejo.heavyduty.builders, not inferred.
The default stays GitHub, so every existing caller is unchanged. install.sh's
snapshot reads the same variable through a byte-identical copy of the builder,
diffed by the tests: a snapshot cached from a forge converge would never fetch
from is worse than no snapshot, and the pin-in-the-name staleness guard cannot
catch a wrong-ORIGIN snapshot, only an old one.
ci-box is a tenant, not a machine role. The topology is a fleet machine
hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule.
That also deletes the docker-in-docker layer the usual setup needs:
bootstrap-tenant.sh already installs Docker and adds the tenant user to the
group, and the isolation a privileged dind sidecar buys is already paid for by
a box that is network-isolated, inbound-less and disposable. rig runner
install refuses Docker for good reason — it converges a MACHINE, where the
blast radius is the machine. Here it is a guest that gets thrown away.
rig forgejo-runner is a new family beside rig runner, which is untouched.
Forgejo registers against an INSTANCE and the token carries the scope, so
there is no --repo to converge toward and nothing to compare; folding that
into one command would make every guard bimodal to share a flag name while the
contract underneath differs. assert_runner_instance asks the same
trust-boundary question about the axis Forgejo actually has. There is no
repoint and no --local, and both absences are explained where an operator
arriving from the GitHub sibling will hit them.
Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so
it is installed 0600 and the mode is re-asserted on every converge — a mode
that drifted leaks the secret silently, since nothing fails and the runner
keeps working. status reports it and never prints the token.
Both downloads verify the published .sha256 before installing: this binary
lands as root and is executed by a systemd unit.
bootstrap --undo learns the guard for the same hazard on the other forge, and
it matters more here — Forgejo has no deregistration endpoint, so the ghost it
would strand has to be deleted by hand.
Known prerequisite, documented rather than assumed: the fetch is
unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true
answers 404 for repos it reports as public. Hosting a registry there needs
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case,
because it is indistinguishable from a wrong ref.
forgejo#109
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:54 +00:00
# The registry's candidate URLs, forge-aware — a byte-identical copy of
# commands/lib/templates.sh's, diffed by test/cli.sh so the two cannot drift
# (#109; the valid_version / warn_bootstrapped precedent). One decision about
# where a registry lives, made in one grammar: a snapshot fetched from a forge
# converge would never fetch from is worse than no snapshot at all.
#
# Copied rather than sourced on purpose. snapshot_templates runs against an
# extracted tree, so sourcing WOULD work here — but it would make the
# installer's behaviour depend on executing code from the tarball it just
# downloaded, ahead of any of it being installed. The installer reads that
# tree (sed for the pin); it does not run it.
templates_archive_urls( ) {
local host = " ${ 1 %/ } " repo = " $2 " ref = " $3 "
case " $host " in
https://github.com| http://github.com| *//github.com)
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' " $host " " $repo " " $ref "
printf '%s/%s/archive/refs/heads/%s.tar.gz\n' " $host " " $repo " " $ref "
printf '%s/%s/archive/%s.tar.gz\n' " $host " " $repo " " $ref " ; ;
*)
printf '%s/%s/archive/%s.tar.gz\n' " $host " " $repo " " $ref " ; ;
esac
}
2026-07-10 20:56:05 +00:00
# --- prerequisites -----------------------------------------------------------
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# curl only when something must be downloaded — a local RIG_INSTALL_SOURCE
# needs none, which is what lets test/cli.sh drive REAL installs offline.
if [ -z " ${ RIG_INSTALL_SOURCE :- } " ] ; then
command -v curl >/dev/null 2>& 1 || die "curl is required but was not found."
fi
2026-07-10 20:56:05 +00:00
command -v tar >/dev/null 2>& 1 || die "tar is required but was not found."
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
if [ -n " ${ RIG_INSTALL_SOURCE :- } " ] ; then
SRCDESC = " local source $RIG_INSTALL_SOURCE "
else
2026-07-18 20:57:13 +00:00
SRCDESC = " $REPO @ ${ REF :- latest -release } " # refined once the tag resolves
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
fi
# Flip $DEST/current to versions/<v> atomically: build the new link beside it,
# rename over. Plain ln -sfn is unlink+create — a window where current names
# nothing and a concurrent 'rig' invocation dies mid-chain. bin/rig's cmd_use
# flips with the same pattern.
flip_current( ) {
ln -sfn " versions/ $1 " " $DEST /current.new. $$ "
mv -Tf " $DEST /current.new. $$ " " $DEST /current "
}
# --- migrate a pre-versioning flat install -----------------------------------
# The old installer put the tree FLAT at $DEST (bin/rig directly under it).
# Move such a tree to versions/<its-VERSION> BEFORE anything else, so the
# upgrade is seamless and the version comparison below sees the truth. The
# move is two renames inside one parent directory — no copying, no window with
# no install — and the operator's tree is preserved bit for bit. Pre-VERSION
# trees (every flat rig install, rig#32) migrate as 0.0.0-unknown.
if [ -e " $DEST /bin/rig " ] && [ ! -d " $DEST /versions " ] ; then
flat_ver = " $( cat " $DEST /VERSION " 2>/dev/null || echo 0.0.0-unknown) "
# The flat tree's VERSION is data from disk, not from this installer — the
# same trust boundary as the new_ver check, so the same gate: a corrupted
# (or hostile) VERSION must not steer the mv/ln below out of versions/.
valid_version " $flat_ver " || die " the flat install's VERSION is not a sane directory name: ' $flat_ver ' — fix $DEST /VERSION (one line, e.g. 0.1.0), then re-run "
log " found a pre-versioning flat install at $DEST (version $flat_ver ) — migrating it into the versioned layout "
staging = " $DEST .migrating. $$ "
mv " $DEST " " $staging "
mkdir -p " $DEST /versions "
mv " $staging " " $DEST /versions/ $flat_ver "
flip_current " $flat_ver "
mkdir -p " $BINDIR "
ln -sfn " $DEST /current/bin/rig " " $BINDIR /rig "
log " migrated: it now lives at $DEST /versions/ $flat_ver (still current) "
fi
2026-07-10 20:56:05 +00:00
# --- temp workspace ----------------------------------------------------------
TMPDIR = " $( mktemp -d) "
cleanup( ) { rm -rf " $TMPDIR " ; }
trap cleanup EXIT
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# --- acquire the tree --------------------------------------------------------
if [ -n " ${ RIG_INSTALL_SOURCE :- } " ] ; then
SRC = " $RIG_INSTALL_SOURCE "
INSTALLED_FROM = " local: $SRC "
if [ -d " $SRC " ] ; then
log " copying local tree $SRC "
mkdir -p " $TMPDIR /tree "
# tar, not cp -a: --exclude=.git, so a working checkout never carries its
# VCS state (or its size) into the install tree.
tar -C " $SRC " --exclude= .git -cf - . | tar -xf - -C " $TMPDIR /tree "
EXTRACTED = " $TMPDIR /tree "
elif [ -f " $SRC " ] ; then
log " extracting local tarball $SRC "
tar -xzf " $SRC " -C " $TMPDIR " || die " failed to extract $SRC "
EXTRACTED = " $( find " $TMPDIR " -mindepth 1 -maxdepth 1 -type d | head -n1) "
else
die " RIG_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC "
fi
else
2026-07-18 20:57:13 +00:00
# Which ref? RIG_REF unset means the latest release — and while no release
# exists (rig cuts its first, 0.1.0, right after #32 lands), that channel
# must FAIL, loudly and with the way out, never silently fall back to
# main: "I installed the latest release" must not quietly mean "I
# installed whatever main was that second".
if [ -z " $REF " ] ; then
log " resolving the latest release of $REPO "
if ! REF = " $( resolve_latest_tag " $REPO " ) " ; then
2026-07-29 14:33:38 +00:00
warn " could not resolve the latest release of $REPO — either no release exists yet, or ${ RIG_HOST :- https : //github.com } was unreachable. "
warn "(install the development tree explicitly with RIG_REF=main when no release exists yet.)"
die " set RIG_REF: e.g. curl -fsSL $( install_script_url) | RIG_REF=main bash "
2026-07-18 20:57:13 +00:00
fi
log " latest release: $REF "
2026-07-29 14:33:38 +00:00
# Same candidate grammar as an explicit pin: on GitHub the tag form wins
# first; on Forgejo the single bare archive URL is the whole list (#111).
mapfile -t urls < <( ref_candidate_urls " $REPO " " $REF " )
2026-07-18 20:57:13 +00:00
else
mapfile -t urls < <( ref_candidate_urls " $REPO " " $REF " )
fi
SRCDESC = " $REPO @ $REF "
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
INSTALLED_FROM = " $REPO @ $REF "
log " installing rig ( $REPO @ $REF ) "
2026-07-18 20:57:13 +00:00
got = ""
for URL in " ${ urls [@] } " ; do
log " downloading $URL "
if curl -fsSL " $URL " -o " $TMPDIR /rig.tar.gz " ; then
got = " $URL "
break
fi
done
[ -n " $got " ] \
2026-07-29 14:33:38 +00:00
|| die " failed to download $REPO @ $REF — no candidate URL worked (host ${ RIG_HOST :- https : //github.com } ; tried tags then heads on GitHub, or the single archive URL on other forges) "
2026-07-10 20:56:05 +00:00
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
log "extracting archive"
tar -xzf " $TMPDIR /rig.tar.gz " -C " $TMPDIR " \
|| die "failed to extract archive"
2026-07-10 20:56:05 +00:00
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# GitHub names the archive's top dir <repo>-<ref> — deriving that name is
# guesswork (it broke for real at box's repo rename). The tarball has
# exactly ONE top-level directory: take the directory, whatever it is
# called, and let the bin/rig check below judge whether it is the right tree.
EXTRACTED = " $( find " $TMPDIR " -mindepth 1 -maxdepth 1 -type d | head -n1) "
fi
[ -n " ${ EXTRACTED :- } " ] || die " could not find the source tree in $SRCDESC "
[ -f " $EXTRACTED /bin/rig " ] || die " source does not contain bin/rig — is $SRCDESC correct? "
2026-07-10 20:56:05 +00:00
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# The tree's own VERSION file names the directory it lands in — the version IS
# the identity of what is being installed, and 'rig versions' lists these names.
new_ver = " $( cat " $EXTRACTED /VERSION " 2>/dev/null || true ) "
[ -n " $new_ver " ] || die "source has no VERSION file — cannot install it as a version"
valid_version " $new_ver " || die " the source's VERSION is not a sane directory name: ' $new_ver ' "
2026-07-10 20:56:05 +00:00
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
set_exec( ) { # $1 = a rig tree: the executable bits install.sh owns
chmod +x " $1 /bin/rig "
if [ -d " $1 /commands " ] ; then
find " $1 /commands " -name '*.sh' -exec chmod +x { } +
fi
}
2026-07-10 20:56:05 +00:00
2026-07-25 13:38:54 +00:00
# snapshot_templates <rig-tree> — best-effort install-time cache of the exact
# registry pin carried by that tree. The pin remains the sole source of truth;
# the directory name makes a stale snapshot invisible after an upgrade.
# Failure is deliberately a warning: rig itself is still a complete install,
# and templates_resolve preserves the live-fetch fallback.
snapshot_templates( ) {
feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner family
rig's CI story was GitHub-shaped end to end. This makes it work against a
self-hosted Forgejo, in three pieces.
The registry fetch becomes forge-aware. templates_resolve hardcoded three
github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because
the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare
paths, Forgejo serves exactly one, and emitting the other two there would mean
two guaranteed 404s per fetch and a failure message listing URLs that never
could have worked. Measured against forgejo.heavyduty.builders, not inferred.
The default stays GitHub, so every existing caller is unchanged. install.sh's
snapshot reads the same variable through a byte-identical copy of the builder,
diffed by the tests: a snapshot cached from a forge converge would never fetch
from is worse than no snapshot, and the pin-in-the-name staleness guard cannot
catch a wrong-ORIGIN snapshot, only an old one.
ci-box is a tenant, not a machine role. The topology is a fleet machine
hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule.
That also deletes the docker-in-docker layer the usual setup needs:
bootstrap-tenant.sh already installs Docker and adds the tenant user to the
group, and the isolation a privileged dind sidecar buys is already paid for by
a box that is network-isolated, inbound-less and disposable. rig runner
install refuses Docker for good reason — it converges a MACHINE, where the
blast radius is the machine. Here it is a guest that gets thrown away.
rig forgejo-runner is a new family beside rig runner, which is untouched.
Forgejo registers against an INSTANCE and the token carries the scope, so
there is no --repo to converge toward and nothing to compare; folding that
into one command would make every guard bimodal to share a flag name while the
contract underneath differs. assert_runner_instance asks the same
trust-boundary question about the axis Forgejo actually has. There is no
repoint and no --local, and both absences are explained where an operator
arriving from the GitHub sibling will hit them.
Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so
it is installed 0600 and the mode is re-asserted on every converge — a mode
that drifted leaks the secret silently, since nothing fails and the runner
keeps working. status reports it and never prints the token.
Both downloads verify the published .sha256 before installing: this binary
lands as root and is executed by a systemd unit.
bootstrap --undo learns the guard for the same hazard on the other forge, and
it matters more here — Forgejo has no deregistration endpoint, so the ghost it
would strand has to be deleted by hand.
Known prerequisite, documented rather than assumed: the fetch is
unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true
answers 404 for repos it reports as public. Hosting a registry there needs
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case,
because it is indistinguishable from a wrong ref.
forgejo#109
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:54 +00:00
local tree = " $1 " pin repo host url got = "" unpack top snapshot
2026-07-25 13:38:54 +00:00
pin = " $( sed -n 's/^RIG_TEMPLATES_PIN=//p' " $tree /commands/lib/templates.sh " 2>/dev/null | head -n1 || true ) "
if [ -z " $pin " ] ; then
warn "installed tree carries no RIG_TEMPLATES_PIN; template registry snapshot skipped."
return 0
fi
repo = " ${ RIG_TEMPLATES_REPO :- heavy -duty/rig-templates } "
feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner family
rig's CI story was GitHub-shaped end to end. This makes it work against a
self-hosted Forgejo, in three pieces.
The registry fetch becomes forge-aware. templates_resolve hardcoded three
github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because
the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare
paths, Forgejo serves exactly one, and emitting the other two there would mean
two guaranteed 404s per fetch and a failure message listing URLs that never
could have worked. Measured against forgejo.heavyduty.builders, not inferred.
The default stays GitHub, so every existing caller is unchanged. install.sh's
snapshot reads the same variable through a byte-identical copy of the builder,
diffed by the tests: a snapshot cached from a forge converge would never fetch
from is worse than no snapshot, and the pin-in-the-name staleness guard cannot
catch a wrong-ORIGIN snapshot, only an old one.
ci-box is a tenant, not a machine role. The topology is a fleet machine
hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule.
That also deletes the docker-in-docker layer the usual setup needs:
bootstrap-tenant.sh already installs Docker and adds the tenant user to the
group, and the isolation a privileged dind sidecar buys is already paid for by
a box that is network-isolated, inbound-less and disposable. rig runner
install refuses Docker for good reason — it converges a MACHINE, where the
blast radius is the machine. Here it is a guest that gets thrown away.
rig forgejo-runner is a new family beside rig runner, which is untouched.
Forgejo registers against an INSTANCE and the token carries the scope, so
there is no --repo to converge toward and nothing to compare; folding that
into one command would make every guard bimodal to share a flag name while the
contract underneath differs. assert_runner_instance asks the same
trust-boundary question about the axis Forgejo actually has. There is no
repoint and no --local, and both absences are explained where an operator
arriving from the GitHub sibling will hit them.
Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so
it is installed 0600 and the mode is re-asserted on every converge — a mode
that drifted leaks the secret silently, since nothing fails and the runner
keeps working. status reports it and never prints the token.
Both downloads verify the published .sha256 before installing: this binary
lands as root and is executed by a systemd unit.
bootstrap --undo learns the guard for the same hazard on the other forge, and
it matters more here — Forgejo has no deregistration endpoint, so the ghost it
would strand has to be deleted by hand.
Known prerequisite, documented rather than assumed: the fetch is
unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true
answers 404 for repos it reports as public. Hosting a registry there needs
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case,
because it is indistinguishable from a wrong ref.
forgejo#109
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:54 +00:00
# The same forge knob templates_resolve reads, from the same variable (#109).
# Snapshot and live fetch MUST agree about where the registry lives: an
# install that cached from GitHub while converge fetches from Forgejo would
# serve a snapshot the pin never named, and the pin-in-the-directory-name
# staleness guard cannot catch a WRONG-ORIGIN snapshot, only an old one.
host = " ${ RIG_TEMPLATES_HOST :- https : //github.com } "
2026-07-25 13:38:54 +00:00
snapshot = " $tree /templates@ $pin "
if ! command -v curl >/dev/null 2>& 1; then
warn " curl is unavailable; template registry snapshot $repo @ $pin was not installed (converge will retry the live fetch). "
return 0
fi
unpack = " $TMPDIR /templates-unpack "
rm -rf " $unpack "
mkdir -p " $unpack "
feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner family
rig's CI story was GitHub-shaped end to end. This makes it work against a
self-hosted Forgejo, in three pieces.
The registry fetch becomes forge-aware. templates_resolve hardcoded three
github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because
the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare
paths, Forgejo serves exactly one, and emitting the other two there would mean
two guaranteed 404s per fetch and a failure message listing URLs that never
could have worked. Measured against forgejo.heavyduty.builders, not inferred.
The default stays GitHub, so every existing caller is unchanged. install.sh's
snapshot reads the same variable through a byte-identical copy of the builder,
diffed by the tests: a snapshot cached from a forge converge would never fetch
from is worse than no snapshot, and the pin-in-the-name staleness guard cannot
catch a wrong-ORIGIN snapshot, only an old one.
ci-box is a tenant, not a machine role. The topology is a fleet machine
hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule.
That also deletes the docker-in-docker layer the usual setup needs:
bootstrap-tenant.sh already installs Docker and adds the tenant user to the
group, and the isolation a privileged dind sidecar buys is already paid for by
a box that is network-isolated, inbound-less and disposable. rig runner
install refuses Docker for good reason — it converges a MACHINE, where the
blast radius is the machine. Here it is a guest that gets thrown away.
rig forgejo-runner is a new family beside rig runner, which is untouched.
Forgejo registers against an INSTANCE and the token carries the scope, so
there is no --repo to converge toward and nothing to compare; folding that
into one command would make every guard bimodal to share a flag name while the
contract underneath differs. assert_runner_instance asks the same
trust-boundary question about the axis Forgejo actually has. There is no
repoint and no --local, and both absences are explained where an operator
arriving from the GitHub sibling will hit them.
Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so
it is installed 0600 and the mode is re-asserted on every converge — a mode
that drifted leaks the secret silently, since nothing fails and the runner
keeps working. status reports it and never prints the token.
Both downloads verify the published .sha256 before installing: this binary
lands as root and is executed by a systemd unit.
bootstrap --undo learns the guard for the same hazard on the other forge, and
it matters more here — Forgejo has no deregistration endpoint, so the ghost it
would strand has to be deleted by hand.
Known prerequisite, documented rather than assumed: the fetch is
unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true
answers 404 for repos it reports as public. Hosting a registry there needs
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case,
because it is indistinguishable from a wrong ref.
forgejo#109
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:54 +00:00
log " downloading template registry snapshot ${ host %/ } / $repo @ $pin "
while IFS = read -r url; do
2026-07-25 13:38:54 +00:00
if curl -fsSL " $url " -o " $TMPDIR /templates.tar.gz " 2>/dev/null; then got = " $url " ; break; fi
feat: Forgejo-native CI — a ci-box tenant and a forgejo-runner family
rig's CI story was GitHub-shaped end to end. This makes it work against a
self-hosted Forgejo, in three pieces.
The registry fetch becomes forge-aware. templates_resolve hardcoded three
github.com archive URLs; RIG_TEMPLATES_HOST now selects the grammar, because
the forges genuinely differ — GitHub serves refs/tags, refs/heads and bare
paths, Forgejo serves exactly one, and emitting the other two there would mean
two guaranteed 404s per fetch and a failure message listing URLs that never
could have worked. Measured against forgejo.heavyduty.builders, not inferred.
The default stays GitHub, so every existing caller is unchanged. install.sh's
snapshot reads the same variable through a byte-identical copy of the builder,
diffed by the tests: a snapshot cached from a forge converge would never fetch
from is worse than no snapshot, and the pin-in-the-name staleness guard cannot
catch a wrong-ORIGIN snapshot, only an old one.
ci-box is a tenant, not a machine role. The topology is a fleet machine
hosting boxes, one of which runs CI — a '-box' guest by rig's own family rule.
That also deletes the docker-in-docker layer the usual setup needs:
bootstrap-tenant.sh already installs Docker and adds the tenant user to the
group, and the isolation a privileged dind sidecar buys is already paid for by
a box that is network-isolated, inbound-less and disposable. rig runner
install refuses Docker for good reason — it converges a MACHINE, where the
blast radius is the machine. Here it is a guest that gets thrown away.
rig forgejo-runner is a new family beside rig runner, which is untouched.
Forgejo registers against an INSTANCE and the token carries the scope, so
there is no --repo to converge toward and nothing to compare; folding that
into one command would make every guard bimodal to share a flag name while the
contract underneath differs. assert_runner_instance asks the same
trust-boundary question about the axis Forgejo actually has. There is no
repoint and no --local, and both absences are explained where an operator
arriving from the GitHub sibling will hit them.
Forgejo's .runner holds the runner's own long-lived token, unlike GitHub's, so
it is installed 0600 and the mode is re-asserted on every converge — a mode
that drifted leaks the secret silently, since nothing fails and the runner
keeps working. status reports it and never prints the token.
Both downloads verify the published .sha256 before installing: this binary
lands as root and is executed by a systemd unit.
bootstrap --undo learns the guard for the same hazard on the other forge, and
it matters more here — Forgejo has no deregistration endpoint, so the ghost it
would strand has to be deleted by hand.
Known prerequisite, documented rather than assumed: the fetch is
unauthenticated by contract, and a Forgejo with REQUIRE_SIGNIN_VIEW=true
answers 404 for repos it reports as public. Hosting a registry there needs
FORGEJO__service__REQUIRE_SIGNIN_VIEW=false. The refusal names that case,
because it is indistinguishable from a wrong ref.
forgejo#109
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 20:40:54 +00:00
done < <( templates_archive_urls " $host " " $repo " " $pin " )
2026-07-25 13:38:54 +00:00
if [ -z " $got " ] ; then
warn " could not fetch template registry snapshot $repo @ $pin ; rig installed without it (converge will retry the live fetch). "
return 0
fi
if ! tar -xzf " $TMPDIR /templates.tar.gz " -C " $unpack " ; then
warn " could not extract template registry snapshot from $got ; rig installed without it (converge will retry the live fetch). "
return 0
fi
set -- " $unpack " /*/
if ! { [ $# -eq 1 ] && [ -d " $1 " ] ; } ; then
warn " template registry snapshot from $got has an unexpected archive shape; rig installed without it (converge will retry the live fetch). "
return 0
fi
top = " ${ 1 %/ } "
if [ -z " $( find " $top " -mindepth 2 -maxdepth 2 -type f -name template.env -print -quit 2>/dev/null) " ] ; then
warn " template registry snapshot from $got has no definitions; rig installed without it (converge will retry the live fetch). "
return 0
fi
rm -rf " $snapshot "
mv " $top " " $snapshot "
log " template registry snapshot installed: $repo @ $pin "
}
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# --- install into $DEST/versions/<version> -----------------------------------
VDIR = " $DEST /versions/ $new_ver "
newly_installed = 0
if [ -d " $VDIR " ] ; then
if [ -n " ${ RIG_REINSTALL :- } " ] ; then
# Replace THIS version's tree, as atomically as two renames allow — never
# a partial overlay of new files onto an old tree.
log " RIG_REINSTALL=1 — replacing the installed $new_ver tree "
stage = " $VDIR .new. $$ " ; old = " $VDIR .old. $$ "
rm -rf " $stage " " $old "
2026-07-25 13:38:54 +00:00
snapshot_templates " $EXTRACTED "
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
set_exec " $EXTRACTED "
mv " $EXTRACTED " " $stage "
# Swap by renames, delete LAST: rm-then-move leaves a hole the whole
# length of the delete where current -> this version resolves to nothing.
mv " $VDIR " " $old "
mv " $stage " " $VDIR "
rm -rf " $old "
printf '%s\n' " $INSTALLED_FROM " > " $VDIR /INSTALLED_FROM "
log " reinstalled $new_ver "
else
cur_from = " $( cat " $VDIR /INSTALLED_FROM " 2>/dev/null || echo '<unknown source>' ) "
log " rig $new_ver is already installed ( $cur_from ) — nothing to do. "
log "(RIG_REINSTALL=1 replaces this version's tree; 'rig versions' lists what is installed.)"
fi
else
log " installing $new_ver into $VDIR "
mkdir -p " $DEST /versions "
2026-07-25 13:38:54 +00:00
snapshot_templates " $EXTRACTED "
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
set_exec " $EXTRACTED "
mv " $EXTRACTED " " $VDIR "
newly_installed = 1
# Record WHAT was installed, so a caller can assert it got what it asked
# for — an installer invoked with stale env vars silently falls back to the
# defaults, and INSTALLED_FROM is how that lie gets caught.
printf '%s\n' " $INSTALLED_FROM " > " $VDIR /INSTALLED_FROM "
fi
# --- which version is the default? -------------------------------------------
# 'current' is the tracked default; flipping it is the ONLY step that changes
# what an operator's `rig` runs. A fresh host (or a dangling current) is
# claimed outright; an upgrade flips — with the bootstrapped-host warning —
# because a re-run that silently left you on the old version would make
# "re-run any time to upgrade" a lie. Judged from versions/<v> itself
# (readlink -f), never from what a wedged current claims.
cur = " $( readlink -f " $DEST /current " 2>/dev/null || true ) "
want = " $( readlink -f " $VDIR " ) "
if [ -z " $cur " ] || [ ! -d " $cur " ] ; then
flip_current " $new_ver "
log " default version: $new_ver "
elif [ " $cur " = " $want " ] ; then
: # already the default — nothing to flip
elif [ " $newly_installed " -eq 0 ] ; then
# A converge/no-op (or RIG_REINSTALL) of a version that is NOT the default
# never moves the default — a re-run must change nothing; switching is
# 'rig use', a deliberate act.
log " the default stays $( basename " $cur " ) — 'rig use $new_ver ' switches. "
else
old_ver = " $( basename " $cur " ) "
warn_bootstrapped " switching the default rig version ( $old_ver -> $new_ver ) "
flip_current " $new_ver "
log " default version switched: $old_ver -> $new_ver ('rig use $old_ver ' switches back) "
fi
2026-07-10 20:56:05 +00:00
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# --- put rig on PATH ---------------------------------------------------------
# ln -sfn converges, and that includes HEALING: a stale or dangling
# $BINDIR/rig (say, its tree half-removed by hand) must never block or wedge
# an install — it gets repointed at the current chain, whatever it said before.
2026-07-10 20:56:05 +00:00
mkdir -p " $BINDIR "
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
ln -sfn " $DEST /current/bin/rig " " $BINDIR /rig "
log " linked $BINDIR /rig -> $DEST /current/bin/rig "
2026-07-10 20:56:05 +00:00
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
# --- PATH check --------------------------------------------------------------
2026-07-10 20:56:05 +00:00
case " : $PATH : " in
*" : $BINDIR : " *) : ; ;
*)
warn " $BINDIR is not on your PATH. "
warn " add: export PATH=\" $BINDIR :\$PATH\" "
; ;
esac
feat(install): versioned installs and a real uninstall — box#79's layout, ported
install.sh now lands every version at <root>/versions/<v> (each tree
carrying its own VERSION + INSTALLED_FROM), tracks the default through an
atomically-flipped 'current' symlink, and converges instead of clobbering:
a same-version re-run is a no-op that says so, RIG_REINSTALL=1 replaces
that version's tree by two renames (delete last), and a new version
installs side by side. A pre-versioning flat tree is migrated in place —
two renames, preserved bit for bit, VERSION-less trees as 0.0.0-unknown.
bin/rig grows the table verbs: 'rig versions' (current + running marked),
'rig use <v>' (atomic flip, asserted effective through the PATH chain),
'rig uninstall [<v>|--all]' — which ENDS with an absence assert: every
removed path re-checked, survivors exit 1 as 'uninstall INCOMPLETE' by
name. One strict valid_version gate guards every place a version string
becomes a path (byte-identical copies in bin/rig and install.sh, diffed by
the suite so they cannot drift). Plus the VERSION file and 'rig --version'
(rig#32's first item, folded in minimally — rig main had neither).
The flip gate is rig's own shape, deliberately: box refuses flips under
existing boxes; rig's stake is the converged host, so a flip (upgrade,
'rig use', full uninstall) on a host where /etc/rig/role exists WARNS and
proceeds — no user state to strand, and upgrading a bootstrapped host is
the normal case.
The suite drives REAL installer runs (RIG_INSTALL_SOURCE against throwaway
RIG_HOME/RIG_BIN roots): fresh install, converge, reinstall, side-by-side
upgrade, use/rollback, both migrations, hostile flat VERSION, wedged-
symlink healing, the marker warn gate (RIG_ROLE_MARKER fixtures), both
uninstalls and the INCOMPLETE scream — driven, not grepped.
Closes #35.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 19:33:17 +00:00
log " done ( $SRCDESC , version $new_ver ) — try: rig --help "