feat: install channel is forge-agnostic (RIG_HOST / BOX_HOST) #114
6 changed files with 393 additions and 37 deletions
10
bin/rig
10
bin/rig
|
|
@ -152,10 +152,12 @@ commands:
|
||||||
|
|
||||||
install/upgrade:
|
install/upgrade:
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
||||||
Installs the latest RELEASE (RIG_REF=<tag> pins one, RIG_REF=main
|
Installs the latest RELEASE from GitHub (the default RIG_HOST).
|
||||||
tracks the development tree). Re-run any time: an installed version
|
RIG_REF=<tag> pins one, RIG_REF=main tracks the development tree.
|
||||||
converges (no-op), a new one installs side by side at
|
From a Forgejo host: set RIG_HOST to that origin and fetch install.sh
|
||||||
<root>/versions/<v> and becomes the default.
|
from <host>/heavy-duty/rig/raw/branch/main/install.sh (#111).
|
||||||
|
Re-run any time: an installed version converges (no-op), a new one
|
||||||
|
installs side by side at <root>/versions/<v> and becomes the default.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
3
changelog.d/111.md
Normal file
3
changelog.d/111.md
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- `install.sh` and bootstrap's box fetch take `RIG_HOST` / `BOX_HOST` so a Forgejo origin can serve the install channel (#111)
|
||||||
|
|
@ -723,22 +723,111 @@ if [ "$HOST" = "yes" ]; then
|
||||||
BOX_RELEASE=0.9.0
|
BOX_RELEASE=0.9.0
|
||||||
BOX_REPO="${BOX_REPO:-heavy-duty/box}"
|
BOX_REPO="${BOX_REPO:-heavy-duty/box}"
|
||||||
BOX_REF="${BOX_REF:-$BOX_RELEASE}"
|
BOX_REF="${BOX_REF:-$BOX_RELEASE}"
|
||||||
BOX_INSTALL_URL="https://raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh"
|
# BOX_HOST: which forge serves box's *installer script* (#111). Parallel to
|
||||||
BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 BOX_REF=${BOX_REF} bash"
|
# RIG_HOST / RIG_TEMPLATES_HOST. Defaults to RIG_HOST when set, else GitHub,
|
||||||
|
# so a Forgejo-sourced rig stays Forgejo-native for this fetch without a
|
||||||
|
# second knob — override with BOX_HOST when the two must diverge.
|
||||||
|
# Raw-file grammar:
|
||||||
|
# GitHub raw.githubusercontent.com/<repo>/<ref>/install.sh
|
||||||
|
# Forgejo <host>/<repo>/raw/{tag|branch}/<ref>/install.sh
|
||||||
|
# Forgejo's bare /raw/<ref>/ is branch-first (opposite of /archive/<ref>),
|
||||||
|
# so we never guess kind from spelling: try /raw/tag/ then /raw/branch/
|
||||||
|
# and let the fetch decide (same pin-wins rule as ref_candidate_urls).
|
||||||
|
# SCOPE: this only moves the script fetch. box@0.9.0's installer still
|
||||||
|
# hardcodes GitHub for its own archive — zero-GitHub bootstrap needs a
|
||||||
|
# BOX_HOST knob in heavy-duty/box (tracked separately).
|
||||||
|
BOX_HOST="${BOX_HOST:-${RIG_HOST:-https://github.com}}"
|
||||||
|
BOX_HOST="${BOX_HOST%/}"
|
||||||
|
box_install_urls() {
|
||||||
|
case "$BOX_HOST" in
|
||||||
|
https://github.com|http://github.com|*//github.com)
|
||||||
|
printf 'https://raw.githubusercontent.com/%s/%s/install.sh\n' "$BOX_REPO" "$BOX_REF" ;;
|
||||||
|
*)
|
||||||
|
printf '%s/%s/raw/tag/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF"
|
||||||
|
printf '%s/%s/raw/branch/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
# One pasteable recovery command per candidate URL. Never join candidates
|
||||||
|
# with English prose or shell metacharacters — "curl A | bash; if that
|
||||||
|
# 404s: curl B | bash" is not valid shell (`bash -n` exits 2) and is the
|
||||||
|
# same class of operator-facing failure #111 exists to remove (#125 /
|
||||||
|
# codex REQUEST_CHANGES on !114). Multi-candidate display uses separate
|
||||||
|
# prefixed lines (try: / or:); a single candidate (GitHub default) is a
|
||||||
|
# bare pasteable command — a try: prefix turns paste into a silent no-op
|
||||||
|
# (`try:` is not a command; the pipe's bash still exits 0). After a live
|
||||||
|
# probe succeeds the install loop rewrites BOX_MANUAL to the single URL
|
||||||
|
# that worked. Consumers MUST emit via box_manual_emit — never interpolate
|
||||||
|
# ${BOX_MANUAL} into a single log/warn string (multi-line orphans the or:
|
||||||
|
# line; claude REQUEST_CHANGES on 1c9a245).
|
||||||
|
box_manual_cmd() { # box_manual_cmd <url> — one pasteable install line
|
||||||
|
printf 'curl -fsSL %s | BOX_YES=1 BOX_REF=%s bash\n' "$1" "$BOX_REF"
|
||||||
|
}
|
||||||
|
box_manual_text() {
|
||||||
|
local _n=0 _url _cmd _urls=()
|
||||||
|
while IFS= read -r _url; do
|
||||||
|
[ -n "$_url" ] && _urls+=("$_url")
|
||||||
|
done < <(box_install_urls)
|
||||||
|
# Single candidate: bare command (no try:). Multi: try:/or: lines.
|
||||||
|
if [ "${#_urls[@]}" -le 1 ]; then
|
||||||
|
if [ "${#_urls[@]}" -eq 1 ]; then
|
||||||
|
_cmd="$(box_manual_cmd "${_urls[0]}")"
|
||||||
|
printf '%s' "$_cmd"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
for _url in "${_urls[@]}"; do
|
||||||
|
_cmd="$(box_manual_cmd "$_url")"
|
||||||
|
_cmd="${_cmd%$'\n'}"
|
||||||
|
_n=$((_n + 1))
|
||||||
|
if [ "$_n" -eq 1 ]; then
|
||||||
|
printf 'try: %s\n' "$_cmd"
|
||||||
|
else
|
||||||
|
printf 'or: %s\n' "$_cmd"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
# Emit BOX_MANUAL one line at a time through log or warn. Never splice the
|
||||||
|
# multi-line value into a prose sentence.
|
||||||
|
box_manual_emit() { # box_manual_emit log|warn
|
||||||
|
local _fn="$1" _line
|
||||||
|
while IFS= read -r _line; do
|
||||||
|
[ -n "$_line" ] && "$_fn" " ${_line}"
|
||||||
|
done <<EOF
|
||||||
|
$BOX_MANUAL
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
BOX_INSTALL_URL="$(box_install_urls | head -n1)"
|
||||||
|
# Newline-separated recovery lines — each command (after optional try:/or:
|
||||||
|
# prefix) is independently pasteable.
|
||||||
|
BOX_MANUAL="$(box_manual_text)"
|
||||||
if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
|
if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then
|
||||||
log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}"
|
log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later:"
|
||||||
|
box_manual_emit log
|
||||||
elif ! command -v curl >/dev/null 2>&1; then
|
elif ! command -v curl >/dev/null 2>&1; then
|
||||||
warn "curl not found — skipping box install; once curl is present, prepare Incus with: ${BOX_MANUAL}"
|
warn "curl not found — skipping box install; once curl is present, prepare Incus with:"
|
||||||
|
box_manual_emit warn
|
||||||
else
|
else
|
||||||
log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig"
|
log "installing box (${BOX_REPO}@${BOX_REF}) and running its host setup — box owns Incus, not rig"
|
||||||
# BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box
|
# BOX_YES=1 in the environment: non-interactive AND keeps setup-host, so box
|
||||||
# builds the Incus stack rather than only dropping the CLI on PATH. Running as
|
# builds the Incus stack rather than only dropping the CLI on PATH. Running as
|
||||||
# root, box installs globally (/opt/box + /usr/local/bin). No-op if box is
|
# root, box installs globally (/opt/box + /usr/local/bin). No-op if box is
|
||||||
# already installed, so re-running bootstrap converges instead of reinstalling.
|
# already installed, so re-running bootstrap converges instead of reinstalling.
|
||||||
# A curl failure (no network) fails the pipe under pipefail and lands in the
|
# Download and execute are separate so a 404 on /raw/tag/ can fall through
|
||||||
# else — a warning, never an abort: box is the host extra, the OS+tailnet core
|
# to /raw/branch/ without running a half-fetched body, and so an installer
|
||||||
# is already done.
|
# that runs and fails is NOT retried against the next candidate.
|
||||||
if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 BOX_REF="$BOX_REF" bash; then
|
# A curl failure (no network) lands in the else — a warning, never an
|
||||||
|
# abort: box is the host extra, the OS+tailnet core is already done.
|
||||||
|
BOX_SCRIPT="$(mktemp)"
|
||||||
|
BOX_GOT=""
|
||||||
|
while IFS= read -r _box_url; do
|
||||||
|
if curl -fsSL "$_box_url" -o "$BOX_SCRIPT"; then
|
||||||
|
BOX_GOT="$_box_url"
|
||||||
|
BOX_INSTALL_URL="$_box_url"
|
||||||
|
BOX_MANUAL="$(box_manual_cmd "$BOX_INSTALL_URL" | tr -d '\n')"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done < <(box_install_urls)
|
||||||
|
if [ -n "$BOX_GOT" ] && BOX_YES=1 BOX_REF="$BOX_REF" bash "$BOX_SCRIPT"; then
|
||||||
# Don't trust the exit code — prove the effective state (issue #12). An
|
# Don't trust the exit code — prove the effective state (issue #12). An
|
||||||
# installer can exit 0 having done less than it claims: box's setup-host
|
# installer can exit 0 having done less than it claims: box's setup-host
|
||||||
# is written for a sudo-capable user, and one of its paths exits 0 after
|
# is written for a sudo-capable user, and one of its paths exits 0 after
|
||||||
|
|
@ -758,14 +847,18 @@ if [ "$HOST" = "yes" ]; then
|
||||||
if box doctor >/dev/null 2>&1; then
|
if box doctor >/dev/null 2>&1; then
|
||||||
log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'"
|
log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'"
|
||||||
else
|
else
|
||||||
warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand: ${BOX_MANUAL})"
|
warn "box is on PATH but 'box doctor' does not pass — the CLI landed, the host stack is unproven. Run 'box doctor' for the verdict, then 'box setup-host' (or finish by hand:)"
|
||||||
|
box_manual_emit warn
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand: ${BOX_MANUAL}"
|
warn "box's installer reported success but no 'box' is on PATH — the install did not take effect. Finish the host by hand:"
|
||||||
|
box_manual_emit warn
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand: ${BOX_MANUAL}"
|
warn "box install did not complete (no network, or box's installer failed); bootstrap's core work is done. Finish the host by hand:"
|
||||||
|
box_manual_emit warn
|
||||||
fi
|
fi
|
||||||
|
rm -f "$BOX_SCRIPT"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
77
install.sh
77
install.sh
|
|
@ -37,6 +37,11 @@ set -euo pipefail
|
||||||
|
|
||||||
REPO="${RIG_REPO:-heavy-duty/rig}"
|
REPO="${RIG_REPO:-heavy-duty/rig}"
|
||||||
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
REF="${RIG_REF:-}" # empty = the latest release, resolved below
|
||||||
|
# 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.
|
||||||
|
|
||||||
# cloud-init's runcmd runs with NO $HOME in the environment, and under set -u
|
# 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 —
|
# the expansions just below turned that into a death instead of an install —
|
||||||
|
|
@ -101,11 +106,18 @@ warn_bootstrapped() { # $1 = what is about to happen
|
||||||
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
|
# (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
|
# 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,
|
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
|
||||||
# and the CALLER owns the loud story. test/release.sh extracts this function
|
# and the CALLER owns the loud story. Host comes from RIG_HOST (default
|
||||||
# (awk, the valid_version idiom) and drives it against a stubbed curl.
|
# 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.
|
||||||
resolve_latest_tag() {
|
resolve_latest_tag() {
|
||||||
local loc
|
# Default is inlined (not $RIG_HOST_DEFAULT) so test/release.sh's awk
|
||||||
loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$1/releases/latest")" || return 1
|
# 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
|
||||||
case "$loc" in
|
case "$loc" in
|
||||||
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
|
*/releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
|
|
@ -113,12 +125,46 @@ resolve_latest_tag() {
|
||||||
}
|
}
|
||||||
|
|
||||||
# ref_candidate_urls <owner/repo> <ref> — the download candidates for an
|
# ref_candidate_urls <owner/repo> <ref> — the download candidates for an
|
||||||
# explicit RIG_REF, in order: refs/tags first, so a tag always outranks a
|
# explicit RIG_REF, in order. Host comes from RIG_HOST. Both GitHub and
|
||||||
# branch that happens to share its name (the pin must win), refs/heads as
|
# Forgejo (measured 2026-07-29 on forgejo.heavyduty.builders 8.0.3) serve
|
||||||
# the fallback that keeps RIG_REF=main the dev channel.
|
# the same two paths and the same disambiguation: refs/tags first so a pin
|
||||||
|
# always outranks a same-named branch, then refs/heads for RIG_REF=main.
|
||||||
|
# Host is the only forge-specific input — no second grammar (#111).
|
||||||
|
#
|
||||||
|
# The RELEASE channel (RIG_REF unset) must NOT use this list: a missing tag
|
||||||
|
# archive must fail loudly, never fall through to a same-named branch and
|
||||||
|
# still report the resolved tag in INSTALLED_FROM. Use release_tag_url.
|
||||||
ref_candidate_urls() {
|
ref_candidate_urls() {
|
||||||
printf 'https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$1" "$2"
|
local host="${RIG_HOST:-https://github.com}"
|
||||||
printf 'https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$1" "$2"
|
host="${host%/}"
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
# release_tag_url <owner/repo> <tag> — the RELEASE channel is tag-only on
|
||||||
|
# every forge (#111 / #32). One URL, refs/tags only: if that archive is
|
||||||
|
# gone the install dies, it never quietly takes refs/heads/<tag>.
|
||||||
|
release_tag_url() {
|
||||||
|
local host="${RIG_HOST:-https://github.com}"
|
||||||
|
host="${host%/}"
|
||||||
|
printf '%s/%s/archive/refs/tags/%s.tar.gz\n' "$host" "$1" "$2"
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
|
||||||
}
|
}
|
||||||
|
|
||||||
# The registry's candidate URLs, forge-aware — a byte-identical copy of
|
# The registry's candidate URLs, forge-aware — a byte-identical copy of
|
||||||
|
|
@ -223,12 +269,15 @@ else
|
||||||
if [ -z "$REF" ]; then
|
if [ -z "$REF" ]; then
|
||||||
log "resolving the latest release of $REPO"
|
log "resolving the latest release of $REPO"
|
||||||
if ! REF="$(resolve_latest_tag "$REPO")"; then
|
if ! REF="$(resolve_latest_tag "$REPO")"; then
|
||||||
warn "could not resolve the latest release of $REPO — either no release exists yet, or GitHub was unreachable."
|
warn "could not resolve the latest release of $REPO — either no release exists yet, or ${RIG_HOST:-https://github.com} was unreachable."
|
||||||
warn "(rig has no release until 0.1.0 is cut — rig#32. Until then, install the development tree explicitly.)"
|
warn "(install the development tree explicitly with RIG_REF=main when no release exists yet.)"
|
||||||
die "set RIG_REF: e.g. curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | RIG_REF=main bash"
|
die "set RIG_REF: e.g. curl -fsSL $(install_script_url) | RIG_REF=main bash"
|
||||||
fi
|
fi
|
||||||
log "latest release: $REF"
|
log "latest release: $REF"
|
||||||
urls=("https://github.com/$REPO/archive/refs/tags/$REF.tar.gz")
|
# Tag-only: the channel resolved a RELEASE tag, so the download is that
|
||||||
|
# tag's archive and nothing else. Falling through to refs/heads would
|
||||||
|
# install a branch while INSTALLED_FROM still names the tag (#111 review).
|
||||||
|
mapfile -t urls < <(release_tag_url "$REPO" "$REF")
|
||||||
else
|
else
|
||||||
mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF")
|
mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF")
|
||||||
fi
|
fi
|
||||||
|
|
@ -244,7 +293,7 @@ else
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
[ -n "$got" ] \
|
[ -n "$got" ] \
|
||||||
|| die "failed to download $REPO@$REF — not a tag and not a branch (tried refs/tags then refs/heads)"
|
|| die "failed to download $REPO@$REF — no candidate URL worked (host ${RIG_HOST:-https://github.com}; tried ${urls[*]})"
|
||||||
|
|
||||||
log "extracting archive"
|
log "extracting archive"
|
||||||
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
|
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
|
||||||
|
|
|
||||||
113
test/cli.sh
113
test/cli.sh
|
|
@ -244,11 +244,122 @@ check "bootstrap: BOX_REF overrides the released default" 0 "" \
|
||||||
check "bootstrap: box install passes BOX_REF through the installer pipe" 0 "" \
|
check "bootstrap: box install passes BOX_REF through the installer pipe" 0 "" \
|
||||||
grep -qF 'BOX_YES=1 BOX_REF="$BOX_REF" bash' "$ROOT/commands/bootstrap.sh"
|
grep -qF 'BOX_YES=1 BOX_REF="$BOX_REF" bash' "$ROOT/commands/bootstrap.sh"
|
||||||
# The same pinned command is operators' recovery path on every skip/failure.
|
# The same pinned command is operators' recovery path on every skip/failure.
|
||||||
|
# box_manual_cmd formats BOX_REF via %s so the rendered recovery always
|
||||||
|
# carries the concrete pin (not a bare unexpanded variable).
|
||||||
# shellcheck disable=SC2016
|
# shellcheck disable=SC2016
|
||||||
check "bootstrap: manual box install carries the pinned ref" 0 "" \
|
check "bootstrap: manual box install carries the pinned ref" 0 "" \
|
||||||
grep -qF 'BOX_YES=1 BOX_REF=${BOX_REF} bash' "$ROOT/commands/bootstrap.sh"
|
grep -qF 'BOX_YES=1 BOX_REF=%s bash' "$ROOT/commands/bootstrap.sh"
|
||||||
check "bootstrap: box repository remains pinnable" 0 "" \
|
check "bootstrap: box repository remains pinnable" 0 "" \
|
||||||
grep -qF 'BOX_REPO:-heavy-duty/box' "$ROOT/commands/bootstrap.sh"
|
grep -qF 'BOX_REPO:-heavy-duty/box' "$ROOT/commands/bootstrap.sh"
|
||||||
|
# BOX_HOST selects the forge that serves box's installer script (#111).
|
||||||
|
# Defaults through RIG_HOST so a Forgejo-sourced rig stays Forgejo-native
|
||||||
|
# for this fetch; a non-GitHub host tries /raw/tag/ then /raw/branch/
|
||||||
|
# (never guesses kind from spelling).
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "bootstrap: BOX_HOST defaults through RIG_HOST then GitHub" 0 "" \
|
||||||
|
grep -qF 'BOX_HOST="${BOX_HOST:-${RIG_HOST:-https://github.com}}"' "$ROOT/commands/bootstrap.sh"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "bootstrap: GitHub box install uses raw.githubusercontent.com" 0 "" \
|
||||||
|
grep -qF 'raw.githubusercontent.com/%s/%s/install.sh' "$ROOT/commands/bootstrap.sh"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "bootstrap: non-GitHub box install tries /raw/tag/ first" 0 "" \
|
||||||
|
grep -qF 'raw/tag/%s/install.sh' "$ROOT/commands/bootstrap.sh"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "bootstrap: non-GitHub box install falls back to /raw/branch/" 0 "" \
|
||||||
|
grep -qF 'raw/branch/%s/install.sh' "$ROOT/commands/bootstrap.sh"
|
||||||
|
# Drive box_install_urls for real (codex/claude: grep-only cannot catch
|
||||||
|
# order or emission bugs). Extract with the release.sh awk idiom; the
|
||||||
|
# function is nested under `if [ "$HOST" = "yes" ]` so strip two spaces.
|
||||||
|
BIU_DIR="$(mktemp -d)"
|
||||||
|
BIU="$BIU_DIR/box-install-urls.sh"
|
||||||
|
awk '/^ box_install_urls\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh" \
|
||||||
|
| sed 's/^ //' > "$BIU"
|
||||||
|
check "bootstrap: box_install_urls extracted (guards the awk)" 0 "raw" cat "$BIU"
|
||||||
|
biu_line() { # biu_line HOST REF N — the Nth candidate (1-based)
|
||||||
|
local host="$1" ref="$2" n="$3"
|
||||||
|
# shellcheck disable=SC2016 # $1/$2 are the inner bash -c positionals
|
||||||
|
env BOX_HOST="$host" BOX_REPO=heavy-duty/box BOX_REF="$ref" \
|
||||||
|
bash -c 'set -euo pipefail; . "$1"; box_install_urls | sed -n "${2}p"' \
|
||||||
|
_ "$BIU" "$n"
|
||||||
|
}
|
||||||
|
biu_count() { # biu_count HOST REF — how many candidates
|
||||||
|
local host="$1" ref="$2"
|
||||||
|
# shellcheck disable=SC2016 # $1 is the inner bash -c positional
|
||||||
|
env BOX_HOST="$host" BOX_REPO=heavy-duty/box BOX_REF="$ref" \
|
||||||
|
bash -c 'set -euo pipefail; . "$1"; box_install_urls | grep -c .' \
|
||||||
|
_ "$BIU"
|
||||||
|
}
|
||||||
|
check "bootstrap: box_install_urls GitHub is a single raw.githubusercontent.com URL" 0 \
|
||||||
|
"https://raw.githubusercontent.com/heavy-duty/box/0.9.0/install.sh" \
|
||||||
|
biu_line https://github.com 0.9.0 1
|
||||||
|
check "bootstrap: box_install_urls GitHub emits exactly one candidate" 0 "1" \
|
||||||
|
biu_count https://github.com 0.9.0
|
||||||
|
check "bootstrap: box_install_urls Forgejo tag-first for a version pin" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/box/raw/tag/0.9.0/install.sh" \
|
||||||
|
biu_line https://forgejo.example 0.9.0 1
|
||||||
|
check "bootstrap: box_install_urls Forgejo branch second" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/box/raw/branch/0.9.0/install.sh" \
|
||||||
|
biu_line https://forgejo.example 0.9.0 2
|
||||||
|
check "bootstrap: box_install_urls Forgejo tag-first even for BOX_REF=main" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/box/raw/tag/main/install.sh" \
|
||||||
|
biu_line https://forgejo.example main 1
|
||||||
|
# BOX_MANUAL recovery text: multi-candidate → separate try:/or: lines;
|
||||||
|
# single-candidate (GitHub) → bare pasteable command (no try: prefix —
|
||||||
|
# `try: curl…` is a silent no-op under bash -c; claude RC on 1c9a245).
|
||||||
|
# Extract helpers with the same nested-fn idiom.
|
||||||
|
BIM="$BIU_DIR/box-manual.sh"
|
||||||
|
{
|
||||||
|
awk '/^ box_install_urls\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh"
|
||||||
|
awk '/^ box_manual_cmd\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh"
|
||||||
|
awk '/^ box_manual_text\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh"
|
||||||
|
awk '/^ box_manual_emit\(\) \{/,/^ \}/' "$ROOT/commands/bootstrap.sh"
|
||||||
|
} | sed 's/^ //' > "$BIM"
|
||||||
|
check "bootstrap: box_manual helpers extracted" 0 "box_manual_emit" cat "$BIM"
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
bmanual() { # bmanual HOST REF — render BOX_MANUAL text
|
||||||
|
env BOX_HOST="$1" BOX_REPO=heavy-duty/box BOX_REF="$2" \
|
||||||
|
bash -c 'set -euo pipefail; . "$1"; box_manual_text' _ "$BIM"
|
||||||
|
}
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
bmanual_cmds_ok() { # every pasteable command after optional try:/or: passes bash -n
|
||||||
|
env BOX_HOST="$1" BOX_REPO=heavy-duty/box BOX_REF="$2" \
|
||||||
|
bash -c 'set -euo pipefail
|
||||||
|
. "$1"
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
cmd="$line"
|
||||||
|
cmd="${cmd#try: }"
|
||||||
|
cmd="${cmd#or: }"
|
||||||
|
cmd="${cmd#or: }"
|
||||||
|
bash -n <<<"$cmd"
|
||||||
|
done < <(box_manual_text)' _ "$BIM"
|
||||||
|
}
|
||||||
|
# GitHub: bare command, no try: (single candidate — pasteable as-is).
|
||||||
|
check "bootstrap: BOX_MANUAL GitHub is a bare raw.githubusercontent.com command" 0 \
|
||||||
|
"curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/0.9.0/install.sh | BOX_YES=1 BOX_REF=0.9.0 bash" \
|
||||||
|
bmanual https://github.com 0.9.0
|
||||||
|
check "bootstrap: BOX_MANUAL Forgejo lists raw/tag first" 0 \
|
||||||
|
"try: curl -fsSL https://forgejo.example/heavy-duty/box/raw/tag/main/install.sh | BOX_YES=1 BOX_REF=main bash" \
|
||||||
|
bmanual https://forgejo.example main
|
||||||
|
check "bootstrap: BOX_MANUAL Forgejo lists raw/branch as or:" 0 \
|
||||||
|
"or: curl -fsSL https://forgejo.example/heavy-duty/box/raw/branch/main/install.sh | BOX_YES=1 BOX_REF=main bash" \
|
||||||
|
bmanual https://forgejo.example main
|
||||||
|
check "bootstrap: BOX_MANUAL GitHub commands pass bash -n" 0 "" \
|
||||||
|
bmanual_cmds_ok https://github.com 0.9.0
|
||||||
|
check "bootstrap: BOX_MANUAL Forgejo commands pass bash -n" 0 "" \
|
||||||
|
bmanual_cmds_ok https://forgejo.example main
|
||||||
|
# Regression: the old prose join must not return.
|
||||||
|
check "bootstrap: BOX_MANUAL does not use prose 'if that 404s'" 1 "" \
|
||||||
|
grep -qF 'if that 404s' "$ROOT/commands/bootstrap.sh"
|
||||||
|
# claude REQUEST_CHANGES on 1c9a245: multi-line BOX_MANUAL must never be
|
||||||
|
# interpolated into a single log/warn string (orphans the or: line; try:
|
||||||
|
# prefix inside a sentence is not pasteable). Only box_manual_emit may
|
||||||
|
# consume the value, one line at a time.
|
||||||
|
check "bootstrap: BOX_MANUAL never interpolated into log/warn string" 1 "" \
|
||||||
|
grep -nE '(log|warn) .*\$\{BOX_MANUAL\}' "$ROOT/commands/bootstrap.sh"
|
||||||
|
check "bootstrap: box_manual_emit is the sole multi-line consumer" 0 "" \
|
||||||
|
grep -qF 'box_manual_emit' "$ROOT/commands/bootstrap.sh"
|
||||||
|
rm -rf "$BIU_DIR"
|
||||||
# Opt-out for rehearsals / offline / hand-managed hosts.
|
# Opt-out for rehearsals / offline / hand-managed hosts.
|
||||||
check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \
|
check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \
|
||||||
grep -q "RIG_SKIP_BOX_INSTALL" "$ROOT/commands/bootstrap.sh"
|
grep -q "RIG_SKIP_BOX_INSTALL" "$ROOT/commands/bootstrap.sh"
|
||||||
|
|
|
||||||
112
test/release.sh
112
test/release.sh
|
|
@ -42,8 +42,14 @@ FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME"
|
||||||
# inline; extract them here and drive them for real (the valid_version awk
|
# inline; extract them here and drive them for real (the valid_version awk
|
||||||
# idiom from test/cli.sh), against a stub curl — never the network.
|
# idiom from test/cli.sh), against a stub curl — never the network.
|
||||||
RL="$WORK/installer-fns.sh"
|
RL="$WORK/installer-fns.sh"
|
||||||
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$RL"
|
# Grouped redirect — shellcheck SC2129 flags four individual >> to the same
|
||||||
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL"
|
# file (crossed the threshold when release_tag_url joined the extract set).
|
||||||
|
{
|
||||||
|
awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh"
|
||||||
|
awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh"
|
||||||
|
awk '/^release_tag_url\(\) \{/,/^\}/' "$ROOT/install.sh"
|
||||||
|
awk '/^install_script_url\(\) \{/,/^\}/' "$ROOT/install.sh"
|
||||||
|
} > "$RL"
|
||||||
check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL"
|
check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL"
|
||||||
|
|
||||||
STUB="$WORK/stub"; mkdir -p "$STUB"
|
STUB="$WORK/stub"; mkdir -p "$STUB"
|
||||||
|
|
@ -93,14 +99,57 @@ check "resolve: a tagless releases/tag/ redirect fails" 1 "" \
|
||||||
check "resolve: a failing curl fails (network down is not a channel)" 1 "" \
|
check "resolve: a failing curl fails (network down is not a channel)" 1 "" \
|
||||||
rlt CURL_STUB_FAIL=1
|
rlt CURL_STUB_FAIL=1
|
||||||
|
|
||||||
rcu_line() { # rcu_line <n> — the nth candidate URL for an explicit ref
|
rcu_line() { # rcu_line <n> [VAR=val ...] — the nth candidate URL for an explicit ref
|
||||||
bash -c 'set -euo pipefail
|
local n="$1"; shift
|
||||||
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$1"
|
# shellcheck disable=SC2016
|
||||||
|
env "$@" bash -c 'set -euo pipefail
|
||||||
|
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$n"
|
||||||
}
|
}
|
||||||
check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \
|
check "candidates: refs/tags first — the pin outranks a same-named branch" 0 \
|
||||||
"https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1
|
"https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1
|
||||||
check "candidates: refs/heads is the fallback" 0 \
|
check "candidates: refs/heads is the fallback" 0 \
|
||||||
"https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2
|
"https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2
|
||||||
|
# RIG_HOST is host-only (#111): Forgejo serves the same refs/{tags,heads}/
|
||||||
|
# pair (measured 2026-07-29). Host substituted; grammar unchanged.
|
||||||
|
check "candidates: Forgejo host uses the same refs/tags form" 0 \
|
||||||
|
"https://forgejo.example/acme/widgets/archive/refs/tags/1.2.3.tar.gz" \
|
||||||
|
rcu_line 1 RIG_HOST=https://forgejo.example
|
||||||
|
check "candidates: Forgejo host keeps refs/heads as fallback" 0 \
|
||||||
|
"https://forgejo.example/acme/widgets/archive/refs/heads/1.2.3.tar.gz" \
|
||||||
|
rcu_line 2 RIG_HOST=https://forgejo.example
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
check "candidates: Forgejo host emits exactly two candidates" 0 "2" \
|
||||||
|
env RIG_HOST=https://forgejo.example bash -c 'set -euo pipefail
|
||||||
|
. "$1"; ref_candidate_urls acme/widgets 1.2.3 | grep -c .' _ "$RL"
|
||||||
|
check "candidates: trailing slash on RIG_HOST is stripped" 0 \
|
||||||
|
"https://forgejo.example/acme/widgets/archive/refs/tags/1.2.3.tar.gz" \
|
||||||
|
rcu_line 1 RIG_HOST=https://forgejo.example/
|
||||||
|
|
||||||
|
# install_script_url — the curl|bash hint must match the forge (#111).
|
||||||
|
isu() {
|
||||||
|
# shellcheck disable=SC2016
|
||||||
|
env "$@" bash -c 'set -euo pipefail
|
||||||
|
REPO=heavy-duty/rig; . "$1"; install_script_url' _ "$RL"
|
||||||
|
}
|
||||||
|
check "install_script_url: GitHub default uses raw.githubusercontent.com" 0 \
|
||||||
|
"https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh" isu
|
||||||
|
check "install_script_url: Forgejo uses /raw/branch/main/" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/rig/raw/branch/main/install.sh" \
|
||||||
|
isu RIG_HOST=https://forgejo.example
|
||||||
|
|
||||||
|
# resolve_latest_tag follows RIG_HOST too — the probe URL must name the forge.
|
||||||
|
rlt_log="$WORK/rlt-log"
|
||||||
|
: > "$rlt_log"
|
||||||
|
check "resolve: RIG_HOST is the releases/latest origin" 0 "0.2.0" \
|
||||||
|
rlt CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases/tag/0.2.0 \
|
||||||
|
RIG_HOST=https://forgejo.example CURL_STUB_LOG="$rlt_log"
|
||||||
|
check "resolve: the probe hit the Forgejo host" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/rig/releases/latest" \
|
||||||
|
cat "$rlt_log"
|
||||||
|
# Forgejo's no-release path is a 404 (not GitHub's /releases redirect) —
|
||||||
|
# curl -f fails and || return 1 fires. Drive that branch under RIG_HOST.
|
||||||
|
check "resolve: Forgejo no-release is a failing curl (404), not a /releases redirect" 1 "" \
|
||||||
|
rlt RIG_HOST=https://forgejo.example CURL_STUB_FAIL=1
|
||||||
|
|
||||||
# --- the three channels, driven through the REAL installer -------------------
|
# --- the three channels, driven through the REAL installer -------------------
|
||||||
# Full install.sh runs against throwaway roots with the stub curl on PATH: the
|
# Full install.sh runs against throwaway roots with the stub curl on PATH: the
|
||||||
|
|
@ -144,6 +193,27 @@ check "channel latest: the refusal says what is missing" 1 "no release" \
|
||||||
rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
|
rinst "$H2" "$B2" CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases
|
||||||
check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2"
|
check "channel latest: the refusal installed NOTHING" 1 "" test -e "$H2"
|
||||||
|
|
||||||
|
# Channel 1, regression — a resolved tag whose archive is gone must FAIL,
|
||||||
|
# never fall through to refs/heads/<tag> and still claim the release
|
||||||
|
# (claude REQUEST_CHANGES on !114: INSTALLED_FROM would name the tag for a
|
||||||
|
# branch tree). CURL_STUB_OK only matches heads — if the installer tries it,
|
||||||
|
# the install would succeed and this check would fail.
|
||||||
|
H2b="$WORK/h2b"; B2b="$WORK/b2b"; LOG2b="$WORK/log2b"
|
||||||
|
check "channel latest: missing tag archive does NOT fall through to heads" \
|
||||||
|
1 "no candidate URL worked" rinst "$H2b" "$B2b" \
|
||||||
|
CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/3.3.3 \
|
||||||
|
CURL_STUB_OK=refs/heads/3.3.3 CURL_STUB_LOG="$LOG2b"
|
||||||
|
check "channel latest: ...and installed NOTHING (branch was never taken)" 1 "" \
|
||||||
|
test -e "$H2b"
|
||||||
|
# The log also holds the releases/latest probe; the download tries are the
|
||||||
|
# archive URLs. Exactly one archive try, and it is refs/tags — never heads.
|
||||||
|
check "channel latest: ...exactly one archive URL was tried" 0 "1" \
|
||||||
|
grep -c '/archive/' "$LOG2b"
|
||||||
|
check "channel latest: ...that try was refs/tags" 0 "refs/tags/3.3.3" \
|
||||||
|
cat "$LOG2b"
|
||||||
|
check "channel latest: ...refs/heads was never consulted" 1 "" \
|
||||||
|
grep -q 'refs/heads/' "$LOG2b"
|
||||||
|
|
||||||
# Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is
|
# Channel 2 — RIG_REF=<tag>: refs/tags wins, and the latest-release probe is
|
||||||
# never consulted (a pin resolves nothing).
|
# never consulted (a pin resolves nothing).
|
||||||
H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3"
|
H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3"
|
||||||
|
|
@ -167,8 +237,36 @@ check "channel dev: ...then the branch URL" 0 "refs/heads/feature-x" \
|
||||||
|
|
||||||
# Neither a tag nor a branch: both candidates miss, and the die says so.
|
# Neither a tag nor a branch: both candidates miss, and the die says so.
|
||||||
H5="$WORK/h5"; B5="$WORK/b5"
|
H5="$WORK/h5"; B5="$WORK/b5"
|
||||||
check "channel: a ref that is neither tag nor branch dies naming both tries" \
|
check "channel: a ref that is neither tag nor branch dies naming the tries" \
|
||||||
1 "not a tag and not a branch" rinst "$H5" "$B5" RIG_REF=no-such-ref
|
1 "no candidate URL worked" rinst "$H5" "$B5" RIG_REF=no-such-ref
|
||||||
|
|
||||||
|
# Channel 4 — RIG_HOST=Forgejo: same refs/tags→refs/heads candidate order and
|
||||||
|
# the same /releases/latest redirect grammar (#111). The stub succeeds only
|
||||||
|
# when the refs/tags form is requested — a regression that still emitted the
|
||||||
|
# bare /archive/<ref> form would fail here.
|
||||||
|
H9="$WORK/h9"; B9="$WORK/b9"; LOG9="$WORK/log9"
|
||||||
|
check "channel forgejo latest: resolves and installs via refs/tags archive URL" 0 "done" \
|
||||||
|
rinst "$H9" "$B9" RIG_HOST=https://forgejo.example \
|
||||||
|
CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases/tag/7.7.7-relflow \
|
||||||
|
CURL_STUB_OK='/archive/refs/tags/7.7.7-relflow.tar.gz' CURL_STUB_LOG="$LOG9"
|
||||||
|
check "channel forgejo latest: download URL is the refs/tags form" 0 \
|
||||||
|
"https://forgejo.example/heavy-duty/rig/archive/refs/tags/7.7.7-relflow.tar.gz" \
|
||||||
|
cat "$LOG9"
|
||||||
|
check "channel forgejo latest: the tree landed" 0 "" \
|
||||||
|
test -x "$H9/versions/7.7.7-relflow/bin/rig"
|
||||||
|
H10="$WORK/h10"; B10="$WORK/b10"
|
||||||
|
check "channel forgejo pinned: RIG_REF=main falls through to refs/heads" 0 "done" \
|
||||||
|
rinst "$H10" "$B10" RIG_HOST=https://forgejo.example RIG_REF=main \
|
||||||
|
CURL_STUB_OK='/archive/refs/heads/main.tar.gz'
|
||||||
|
check "channel forgejo pinned: the tree landed" 0 "" \
|
||||||
|
test -x "$H10/versions/7.7.7-relflow/bin/rig"
|
||||||
|
# Refusal hint on a non-GitHub host must not send the operator to
|
||||||
|
# raw.githubusercontent.com (that 404s from a Forgejo-only tree).
|
||||||
|
H11="$WORK/h11"; B11="$WORK/b11"
|
||||||
|
check "channel forgejo latest: no-release hint uses the Forgejo raw URL" \
|
||||||
|
1 "https://forgejo.example/heavy-duty/rig/raw/branch/main/install.sh" \
|
||||||
|
rinst "$H11" "$B11" RIG_HOST=https://forgejo.example \
|
||||||
|
CURL_STUB_REDIRECT=https://forgejo.example/heavy-duty/rig/releases
|
||||||
|
|
||||||
# --- the local channel: RIG_INSTALL_SOURCE (#106) ----------------------------
|
# --- the local channel: RIG_INSTALL_SOURCE (#106) ----------------------------
|
||||||
# A supported input, not test scaffolding — CI's `install:` job and test/cli.sh
|
# A supported input, not test scaffolding — CI's `install:` job and test/cli.sh
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue