From 24abfbdb79ce35f7d78f8b255e162fcb9e9ce8f6 Mon Sep 17 00:00:00 2001 From: grok-reviewer-andresmgsl Date: Wed, 29 Jul 2026 14:33:38 +0000 Subject: [PATCH 1/8] feat: install channel is forge-agnostic (RIG_HOST / BOX_HOST) install.sh resolves releases and archive URLs through RIG_HOST (default GitHub), using Forgejo's single /archive/.tar.gz form and the same /releases/latest redirect grammar. Bootstrap's box fetch gets BOX_HOST with forge-aware raw-file URLs. Refusal hints and coolify Documentation= stop pointing only at GitHub. Closes #111 --- bin/rig | 10 ++-- changelog.d/111.md | 3 ++ commands/bootstrap.sh | 21 ++++++++- commands/coolify-backup-install.sh | 4 +- install.sh | 69 ++++++++++++++++++++++------ test/cli.sh | 9 ++++ test/release.sh | 73 ++++++++++++++++++++++++++++-- 7 files changed, 163 insertions(+), 26 deletions(-) create mode 100644 changelog.d/111.md diff --git a/bin/rig b/bin/rig index 1f9b7ee..41a43af 100755 --- a/bin/rig +++ b/bin/rig @@ -152,10 +152,12 @@ commands: install/upgrade: curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash - Installs the latest RELEASE (RIG_REF= pins one, RIG_REF=main - tracks the development tree). Re-run any time: an installed version - converges (no-op), a new one installs side by side at - /versions/ and becomes the default. + Installs the latest RELEASE from GitHub (the default RIG_HOST). + RIG_REF= pins one, RIG_REF=main tracks the development tree. + From a Forgejo host: set RIG_HOST to that origin and fetch install.sh + from /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 /versions/ and becomes the default. EOF } diff --git a/changelog.d/111.md b/changelog.d/111.md new file mode 100644 index 0000000..ffd0cb2 --- /dev/null +++ b/changelog.d/111.md @@ -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) diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index c8cae9e..ae5f934 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -723,7 +723,26 @@ if [ "$HOST" = "yes" ]; then BOX_RELEASE=0.9.0 BOX_REPO="${BOX_REPO:-heavy-duty/box}" 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 (#111). Parallel to RIG_HOST / + # RIG_TEMPLATES_HOST — default GitHub keeps every existing bootstrap + # byte-unchanged; set BOX_HOST=https://forgejo.heavyduty.builders when + # box is installed from this instance. Raw-file grammar differs: + # GitHub raw.githubusercontent.com///install.sh + # Forgejo //raw/{tag|branch}//install.sh + # Version-shaped refs (BOX_RELEASE pins) use raw/tag/; anything else + # (BOX_REF=main) uses raw/branch/. + BOX_HOST="${BOX_HOST:-https://github.com}" + BOX_HOST="${BOX_HOST%/}" + case "$BOX_HOST" in + https://github.com|http://github.com|*//github.com) + BOX_INSTALL_URL="https://raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh" ;; + *) + case "$BOX_REF" in + [0-9]*|v[0-9]*) BOX_RAW_KIND=tag ;; + *) BOX_RAW_KIND=branch ;; + esac + BOX_INSTALL_URL="${BOX_HOST}/${BOX_REPO}/raw/${BOX_RAW_KIND}/${BOX_REF}/install.sh" ;; + esac BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 BOX_REF=${BOX_REF} bash" if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}" diff --git a/commands/coolify-backup-install.sh b/commands/coolify-backup-install.sh index de17bca..b6a2669 100755 --- a/commands/coolify-backup-install.sh +++ b/commands/coolify-backup-install.sh @@ -206,7 +206,7 @@ log "writing ${UNIT_DIR}/coolify-dump.service" cat > "$UNIT_DIR/coolify-dump.service" < "$UNIT_DIR/coolify-dump.timer" < — so this returns 1 there instead of inventing a ref, -# and the CALLER owns the loud story. test/release.sh extracts this function -# (awk, the valid_version idiom) and drives it against a stubbed curl. +# 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/ 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() { - local loc - loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$1/releases/latest")" || return 1 + # 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 case "$loc" in */releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;; *) return 1 ;; @@ -113,12 +125,39 @@ resolve_latest_tag() { } # ref_candidate_urls — the download candidates for an -# explicit RIG_REF, in order: refs/tags first, so a tag always outranks a -# branch that happens to share its name (the pin must win), refs/heads as -# the fallback that keeps RIG_REF=main the dev channel. +# 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/.tar.gz resolves tags, branches and +# SHAs alike (same grammar as templates_archive_urls). ref_candidate_urls() { - printf 'https://github.com/%s/archive/refs/tags/%s.tar.gz\n' "$1" "$2" - printf 'https://github.com/%s/archive/refs/heads/%s.tar.gz\n' "$1" "$2" + 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//. 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 @@ -223,12 +262,14 @@ else if [ -z "$REF" ]; then log "resolving the latest release of $REPO" 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 "(rig has no release until 0.1.0 is cut — rig#32. Until then, install the development tree explicitly.)" - die "set RIG_REF: e.g. curl -fsSL https://raw.githubusercontent.com/$REPO/main/install.sh | RIG_REF=main bash" + 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" fi log "latest release: $REF" - urls=("https://github.com/$REPO/archive/refs/tags/$REF.tar.gz") + # 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") else mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF") fi @@ -244,7 +285,7 @@ else fi done [ -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 tags then heads on GitHub, or the single archive URL on other forges)" log "extracting archive" tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \ diff --git a/test/cli.sh b/test/cli.sh index 060c307..a01506a 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -249,6 +249,15 @@ check "bootstrap: manual box install carries the pinned ref" 0 "" \ grep -qF 'BOX_YES=1 BOX_REF=${BOX_REF} bash' "$ROOT/commands/bootstrap.sh" check "bootstrap: box repository remains pinnable" 0 "" \ grep -qF 'BOX_REPO:-heavy-duty/box' "$ROOT/commands/bootstrap.sh" +# BOX_HOST selects the forge that serves box's installer (#111). Default +# GitHub keeps the raw.githubusercontent.com grammar; a non-GitHub host +# uses Forgejo's /raw/{tag|branch}// form. +check "bootstrap: BOX_HOST defaults to GitHub" 0 "" \ + grep -qF 'BOX_HOST="${BOX_HOST:-https://github.com}"' "$ROOT/commands/bootstrap.sh" +check "bootstrap: GitHub box install uses raw.githubusercontent.com" 0 "" \ + grep -qF 'raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh' "$ROOT/commands/bootstrap.sh" +check "bootstrap: non-GitHub box install uses /raw/{tag|branch}/" 0 "" \ + grep -qF '/raw/${BOX_RAW_KIND}/${BOX_REF}/install.sh' "$ROOT/commands/bootstrap.sh" # Opt-out for rehearsals / offline / hand-managed hosts. check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \ grep -q "RIG_SKIP_BOX_INSTALL" "$ROOT/commands/bootstrap.sh" diff --git a/test/release.sh b/test/release.sh index 8e0103a..3608c5b 100644 --- a/test/release.sh +++ b/test/release.sh @@ -44,6 +44,7 @@ FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" RL="$WORK/installer-fns.sh" awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$RL" awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" +awk '/^install_script_url\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL" STUB="$WORK/stub"; mkdir -p "$STUB" @@ -93,14 +94,49 @@ check "resolve: a tagless releases/tag/ redirect fails" 1 "" \ check "resolve: a failing curl fails (network down is not a channel)" 1 "" \ rlt CURL_STUB_FAIL=1 -rcu_line() { # rcu_line — the nth candidate URL for an explicit ref - bash -c 'set -euo pipefail - . "$1"; ref_candidate_urls acme/widgets 1.2.3 | sed -n "${2}p"' _ "$RL" "$1" +rcu_line() { # rcu_line [VAR=val ...] — the nth candidate URL for an explicit ref + local n="$1"; shift + # 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 \ "https://github.com/acme/widgets/archive/refs/tags/1.2.3.tar.gz" rcu_line 1 check "candidates: refs/heads is the fallback" 0 \ "https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2 +# RIG_HOST selects the forge grammar (#111): non-GitHub hosts emit one bare +# /archive/.tar.gz form (Forgejo), never the refs/{tags,heads}/ pair. +check "candidates: Forgejo host emits the bare archive URL" 0 \ + "https://forgejo.example/acme/widgets/archive/1.2.3.tar.gz" \ + rcu_line 1 RIG_HOST=https://forgejo.example +check "candidates: Forgejo host emits exactly one candidate" 0 "1" \ + 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/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" # --- the three channels, driven through the REAL installer ------------------- # Full install.sh runs against throwaway roots with the stub curl on PATH: the @@ -167,8 +203,35 @@ 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. H5="$WORK/h5"; B5="$WORK/b5" -check "channel: a ref that is neither tag nor branch dies naming both tries" \ - 1 "not a tag and not a branch" rinst "$H5" "$B5" RIG_REF=no-such-ref +check "channel: a ref that is neither tag nor branch dies naming the tries" \ + 1 "no candidate URL worked" rinst "$H5" "$B5" RIG_REF=no-such-ref + +# Channel 4 — RIG_HOST=Forgejo: one archive URL, same redirect grammar for +# latest (#111). The stub only succeeds when the bare /archive/ form is +# requested — a regression that still emitted refs/tags would fail here. +H9="$WORK/h9"; B9="$WORK/b9"; LOG9="$WORK/log9" +check "channel forgejo latest: resolves and installs via bare 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/7.7.7-relflow.tar.gz' CURL_STUB_LOG="$LOG9" +check "channel forgejo latest: download URL is the bare archive form" 0 \ + "https://forgejo.example/heavy-duty/rig/archive/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 uses the bare archive URL" 0 "done" \ + rinst "$H10" "$B10" RIG_HOST=https://forgejo.example RIG_REF=main \ + CURL_STUB_OK='/archive/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) ---------------------------- # A supported input, not test scaffolding — CI's `install:` job and test/cli.sh From df075b9ecf86a35a040e97ed5d215488dacfba68 Mon Sep 17 00:00:00 2001 From: grok-reviewer-andresmgsl Date: Wed, 29 Jul 2026 14:42:59 +0000 Subject: [PATCH 2/8] =?UTF-8?q?fix:=20review=20round=20=E2=80=94=20pin-win?= =?UTF-8?q?s=20candidates,=20no=20BOX=5FRAW=5FKIND=20guess?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address codex REQUEST_CHANGES + claude's live Forgejo measurements: - ref_candidate_urls is host-only: same refs/tags then refs/heads on every forge (Forgejo serves both paths; bare /archive/ no longer special). - bootstrap box raw fetch tries /raw/tag/ then /raw/branch/ and never guesses kind from spelling; download is separate from execute. - BOX_HOST defaults through RIG_HOST; comments narrow the zero-GitHub claim (box@0.9.0 installer still hardcodes GitHub for its archive). - SC2016 suppressions on the new grep assertions; tests updated. --- changelog.d/111.md | 2 +- commands/bootstrap.sh | 62 ++++++++++++++++++++++++++++--------------- install.sh | 26 +++++++----------- test/cli.sh | 23 ++++++++++------ test/release.sh | 35 +++++++++++++----------- 5 files changed, 87 insertions(+), 61 deletions(-) diff --git a/changelog.d/111.md b/changelog.d/111.md index ffd0cb2..edbb7b6 100644 --- a/changelog.d/111.md +++ b/changelog.d/111.md @@ -1,3 +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) +- `install.sh` and bootstrap's box fetch take `RIG_HOST` / `BOX_HOST` so a Forgejo origin can serve the install channel (#111). Archive candidates are host-only (`refs/tags` then `refs/heads` on every forge); box's raw fetch tries `/raw/tag/` then `/raw/branch/` and never guesses kind from spelling. `BOX_HOST` defaults through `RIG_HOST`. diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index ae5f934..b20617d 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -723,26 +723,33 @@ if [ "$HOST" = "yes" ]; then BOX_RELEASE=0.9.0 BOX_REPO="${BOX_REPO:-heavy-duty/box}" BOX_REF="${BOX_REF:-$BOX_RELEASE}" - # BOX_HOST: which forge serves box (#111). Parallel to RIG_HOST / - # RIG_TEMPLATES_HOST — default GitHub keeps every existing bootstrap - # byte-unchanged; set BOX_HOST=https://forgejo.heavyduty.builders when - # box is installed from this instance. Raw-file grammar differs: + # BOX_HOST: which forge serves box's *installer script* (#111). Parallel to + # 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///install.sh # Forgejo //raw/{tag|branch}//install.sh - # Version-shaped refs (BOX_RELEASE pins) use raw/tag/; anything else - # (BOX_REF=main) uses raw/branch/. - BOX_HOST="${BOX_HOST:-https://github.com}" + # Forgejo's bare /raw// is branch-first (opposite of /archive/), + # 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%/}" - case "$BOX_HOST" in - https://github.com|http://github.com|*//github.com) - BOX_INSTALL_URL="https://raw.githubusercontent.com/${BOX_REPO}/${BOX_REF}/install.sh" ;; - *) - case "$BOX_REF" in - [0-9]*|v[0-9]*) BOX_RAW_KIND=tag ;; - *) BOX_RAW_KIND=branch ;; - esac - BOX_INSTALL_URL="${BOX_HOST}/${BOX_REPO}/raw/${BOX_RAW_KIND}/${BOX_REF}/install.sh" ;; - esac + 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 + } + # Prefer the first candidate for the operator-facing manual path; the + # install loop below walks the full list when the network is live. + BOX_INSTALL_URL="$(box_install_urls | head -n1)" BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 BOX_REF=${BOX_REF} bash" if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}" @@ -754,10 +761,22 @@ if [ "$HOST" = "yes" ]; then # 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 # already installed, so re-running bootstrap converges instead of reinstalling. - # A curl failure (no network) fails the pipe under pipefail and lands in the - # else — a warning, never an abort: box is the host extra, the OS+tailnet core - # is already done. - if curl -fsSL "$BOX_INSTALL_URL" | BOX_YES=1 BOX_REF="$BOX_REF" bash; then + # Download and execute are separate so a 404 on /raw/tag/ can fall through + # to /raw/branch/ without running a half-fetched body, and so an installer + # that runs and fails is NOT retried against the next candidate. + # 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="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 BOX_REF=${BOX_REF} bash" + 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 # 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 @@ -785,6 +804,7 @@ if [ "$HOST" = "yes" ]; then 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}" fi + rm -f "$BOX_SCRIPT" fi fi diff --git a/install.sh b/install.sh index c64eb5c..aa480d2 100644 --- a/install.sh +++ b/install.sh @@ -125,22 +125,16 @@ resolve_latest_tag() { } # ref_candidate_urls — the download candidates for an -# 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/.tar.gz resolves tags, branches and -# SHAs alike (same grammar as templates_archive_urls). +# explicit RIG_REF, in order. Host comes from RIG_HOST. Both GitHub and +# Forgejo (measured 2026-07-29 on forgejo.heavyduty.builders 8.0.3) serve +# 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). ref_candidate_urls() { 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 + 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" } # install_script_url — the curl|bash entrypoint URL for this REPO on RIG_HOST. @@ -267,8 +261,8 @@ else die "set RIG_REF: e.g. curl -fsSL $(install_script_url) | RIG_REF=main bash" fi log "latest release: $REF" - # 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). + # Same candidate grammar as an explicit pin: tags first so the pin wins, + # then heads — identical on every forge RIG_HOST names (#111). mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF") else mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF") @@ -285,7 +279,7 @@ else fi done [ -n "$got" ] \ - || 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)" + || die "failed to download $REPO@$REF — no candidate URL worked (host ${RIG_HOST:-https://github.com}; tried refs/tags then refs/heads)" log "extracting archive" tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \ diff --git a/test/cli.sh b/test/cli.sh index a01506a..4812aac 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -249,15 +249,22 @@ check "bootstrap: manual box install carries the pinned ref" 0 "" \ grep -qF 'BOX_YES=1 BOX_REF=${BOX_REF} bash' "$ROOT/commands/bootstrap.sh" check "bootstrap: box repository remains pinnable" 0 "" \ grep -qF 'BOX_REPO:-heavy-duty/box' "$ROOT/commands/bootstrap.sh" -# BOX_HOST selects the forge that serves box's installer (#111). Default -# GitHub keeps the raw.githubusercontent.com grammar; a non-GitHub host -# uses Forgejo's /raw/{tag|branch}// form. -check "bootstrap: BOX_HOST defaults to GitHub" 0 "" \ - grep -qF 'BOX_HOST="${BOX_HOST:-https://github.com}"' "$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/${BOX_REPO}/${BOX_REF}/install.sh' "$ROOT/commands/bootstrap.sh" -check "bootstrap: non-GitHub box install uses /raw/{tag|branch}/" 0 "" \ - grep -qF '/raw/${BOX_RAW_KIND}/${BOX_REF}/install.sh' "$ROOT/commands/bootstrap.sh" + 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" # Opt-out for rehearsals / offline / hand-managed hosts. check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \ grep -q "RIG_SKIP_BOX_INSTALL" "$ROOT/commands/bootstrap.sh" diff --git a/test/release.sh b/test/release.sh index 3608c5b..8b140b0 100644 --- a/test/release.sh +++ b/test/release.sh @@ -104,16 +104,20 @@ 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 check "candidates: refs/heads is the fallback" 0 \ "https://github.com/acme/widgets/archive/refs/heads/1.2.3.tar.gz" rcu_line 2 -# RIG_HOST selects the forge grammar (#111): non-GitHub hosts emit one bare -# /archive/.tar.gz form (Forgejo), never the refs/{tags,heads}/ pair. -check "candidates: Forgejo host emits the bare archive URL" 0 \ - "https://forgejo.example/acme/widgets/archive/1.2.3.tar.gz" \ +# 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 emits exactly one candidate" 0 "1" \ +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/1.2.3.tar.gz" \ + "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). @@ -206,23 +210,24 @@ H5="$WORK/h5"; B5="$WORK/b5" check "channel: a ref that is neither tag nor branch dies naming the tries" \ 1 "no candidate URL worked" rinst "$H5" "$B5" RIG_REF=no-such-ref -# Channel 4 — RIG_HOST=Forgejo: one archive URL, same redirect grammar for -# latest (#111). The stub only succeeds when the bare /archive/ form is -# requested — a regression that still emitted refs/tags would fail here. +# 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/ form would fail here. H9="$WORK/h9"; B9="$WORK/b9"; LOG9="$WORK/log9" -check "channel forgejo latest: resolves and installs via bare archive URL" 0 "done" \ +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/7.7.7-relflow.tar.gz' CURL_STUB_LOG="$LOG9" -check "channel forgejo latest: download URL is the bare archive form" 0 \ - "https://forgejo.example/heavy-duty/rig/archive/7.7.7-relflow.tar.gz" \ + 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 uses the bare archive URL" 0 "done" \ +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/main.tar.gz' + 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 From 1343cb98713b30bc25016c21afc4693845df78bc Mon Sep 17 00:00:00 2001 From: grok-reviewer-andresmgsl Date: Wed, 29 Jul 2026 14:45:40 +0000 Subject: [PATCH 3/8] fix: release channel is tag-only; revert coolify Documentation= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude REQUEST_CHANGES on !114: the latest-release path must never fall through to refs/heads/ (would install a branch while INSTALLED_FROM still names the release). release_tag_url is the single refs/tags URL on every forge; regression test drives the heads-only stub. Also restore coolify Documentation= to github.com — forge flip needs an @andres ruling, not a shipped default in this PR. --- commands/coolify-backup-install.sh | 4 ++-- install.sh | 22 ++++++++++++++++++---- test/release.sh | 22 ++++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/commands/coolify-backup-install.sh b/commands/coolify-backup-install.sh index b6a2669..de17bca 100755 --- a/commands/coolify-backup-install.sh +++ b/commands/coolify-backup-install.sh @@ -206,7 +206,7 @@ log "writing ${UNIT_DIR}/coolify-dump.service" cat > "$UNIT_DIR/coolify-dump.service" < "$UNIT_DIR/coolify-dump.timer" < — 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/. +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//. The refusal hint and bin/rig usage() both print @@ -261,9 +274,10 @@ else die "set RIG_REF: e.g. curl -fsSL $(install_script_url) | RIG_REF=main bash" fi log "latest release: $REF" - # Same candidate grammar as an explicit pin: tags first so the pin wins, - # then heads — identical on every forge RIG_HOST names (#111). - mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF") + # 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 mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF") fi @@ -279,7 +293,7 @@ else fi done [ -n "$got" ] \ - || die "failed to download $REPO@$REF — no candidate URL worked (host ${RIG_HOST:-https://github.com}; 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" tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \ diff --git a/test/release.sh b/test/release.sh index 8b140b0..b5b7bd0 100644 --- a/test/release.sh +++ b/test/release.sh @@ -44,6 +44,7 @@ FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" RL="$WORK/installer-fns.sh" awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$RL" awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" +awk '/^release_tag_url\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" awk '/^install_script_url\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL" @@ -184,6 +185,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 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/ 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=: refs/tags wins, and the latest-release probe is # never consulted (a pin resolves nothing). H3="$WORK/h3"; B3="$WORK/b3"; LOG3="$WORK/log3" From 1070673562c53245f34eeac63a77048ee0c029bc Mon Sep 17 00:00:00 2001 From: grok-reviewer-andresmgsl Date: Wed, 29 Jul 2026 14:45:47 +0000 Subject: [PATCH 4/8] docs: keep changelog.d/111.md aligned with narrowed claim --- changelog.d/111.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/111.md b/changelog.d/111.md index edbb7b6..fa9d2ed 100644 --- a/changelog.d/111.md +++ b/changelog.d/111.md @@ -1,3 +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). Archive candidates are host-only (`refs/tags` then `refs/heads` on every forge); box's raw fetch tries `/raw/tag/` then `/raw/branch/` and never guesses kind from spelling. `BOX_HOST` defaults through `RIG_HOST`. +- `install.sh` and bootstrap's box fetch take `RIG_HOST` / `BOX_HOST` so a Forgejo origin can serve the install channel (#111). Archive candidates are host-only (`refs/tags` then `refs/heads` on every forge); the release channel is tag-only (`release_tag_url`, no heads fallthrough). Box's raw fetch tries `/raw/tag/` then `/raw/branch/` and never guesses kind from spelling. `BOX_HOST` defaults through `RIG_HOST`. From c7d56d3b80f8685a8d1a86106682b85ca10f5d0f Mon Sep 17 00:00:00 2001 From: grok-reviewer-andresmgsl Date: Wed, 29 Jul 2026 14:45:57 +0000 Subject: [PATCH 5/8] docs: shorten changelog.d/111.md under the 300-char entry guard --- changelog.d/111.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/111.md b/changelog.d/111.md index fa9d2ed..ffd0cb2 100644 --- a/changelog.d/111.md +++ b/changelog.d/111.md @@ -1,3 +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). Archive candidates are host-only (`refs/tags` then `refs/heads` on every forge); the release channel is tag-only (`release_tag_url`, no heads fallthrough). Box's raw fetch tries `/raw/tag/` then `/raw/branch/` and never guesses kind from spelling. `BOX_HOST` defaults through `RIG_HOST`. +- `install.sh` and bootstrap's box fetch take `RIG_HOST` / `BOX_HOST` so a Forgejo origin can serve the install channel (#111) From ec73c86de7a38eab72b61c7f8912bc55ba166ac2 Mon Sep 17 00:00:00 2001 From: grok-reviewer-andresmgsl Date: Wed, 29 Jul 2026 14:53:07 +0000 Subject: [PATCH 6/8] fix: shellcheck SC2129, BOX_MANUAL lists candidates, drive box_install_urls Claude re-review on c7d56d3: - Group the installer-fn extracts in test/release.sh (SC2129). - BOX_MANUAL on multi-candidate hosts lists every URL so BOX_REF=main never hands the operator only a 404ing /raw/tag/ form. - Execute box_install_urls under the harness (tag-before-branch order). - Cover Forgejo's no-release 404 path in resolve_latest_tag. --- commands/bootstrap.sh | 17 ++++++++++++++--- test/cli.sh | 40 ++++++++++++++++++++++++++++++++++++++++ test/release.sh | 16 ++++++++++++---- 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index b20617d..c7cce46 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -747,10 +747,21 @@ if [ "$HOST" = "yes" ]; then printf '%s/%s/raw/branch/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF" ;; esac } - # Prefer the first candidate for the operator-facing manual path; the - # install loop below walks the full list when the network is live. + # Operator-facing recovery command. On multi-candidate hosts (Forgejo) + # list every URL — taking only the first would hand BOX_REF=main a + # /raw/tag/main URL that 404s (#111 review: "tells the operator to run a + # command that 404s"). After a live probe succeeds, the install loop + # rewrites BOX_MANUAL to the URL that actually worked. BOX_INSTALL_URL="$(box_install_urls | head -n1)" - BOX_MANUAL="curl -fsSL ${BOX_INSTALL_URL} | BOX_YES=1 BOX_REF=${BOX_REF} bash" + BOX_MANUAL="" + while IFS= read -r _box_manual_url; do + _box_manual_cmd="curl -fsSL ${_box_manual_url} | BOX_YES=1 BOX_REF=${BOX_REF} bash" + if [ -z "$BOX_MANUAL" ]; then + BOX_MANUAL="$_box_manual_cmd" + else + BOX_MANUAL="${BOX_MANUAL}; if that 404s: ${_box_manual_cmd}" + fi + done < <(box_install_urls) if [ "${RIG_SKIP_BOX_INSTALL:-}" = "1" ]; then log "RIG_SKIP_BOX_INSTALL=1 — skipping box install; to prepare Incus by hand later: ${BOX_MANUAL}" elif ! command -v curl >/dev/null 2>&1; then diff --git a/test/cli.sh b/test/cli.sh index 4812aac..e77a2aa 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -265,6 +265,46 @@ check "bootstrap: non-GitHub box install tries /raw/tag/ first" 0 "" \ # 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 must not hand the operator only a 404ing first candidate. +check "bootstrap: BOX_MANUAL names the branch fallback when tag may 404" 0 "" \ + grep -qF 'if that 404s:' "$ROOT/commands/bootstrap.sh" +rm -rf "$BIU_DIR" # Opt-out for rehearsals / offline / hand-managed hosts. check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \ grep -q "RIG_SKIP_BOX_INSTALL" "$ROOT/commands/bootstrap.sh" diff --git a/test/release.sh b/test/release.sh index b5b7bd0..2bd3f0e 100644 --- a/test/release.sh +++ b/test/release.sh @@ -42,10 +42,14 @@ FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" # 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. RL="$WORK/installer-fns.sh" -awk '/^resolve_latest_tag\(\) \{/,/^\}/' "$ROOT/install.sh" > "$RL" -awk '/^ref_candidate_urls\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" -awk '/^release_tag_url\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" -awk '/^install_script_url\(\) \{/,/^\}/' "$ROOT/install.sh" >> "$RL" +# Grouped redirect — shellcheck SC2129 flags four individual >> to the same +# 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" STUB="$WORK/stub"; mkdir -p "$STUB" @@ -142,6 +146,10 @@ check "resolve: RIG_HOST is the releases/latest origin" 0 "0.2.0" \ 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 ------------------- # Full install.sh runs against throwaway roots with the stub curl on PATH: the From 1c9a245595fcc8fbcf2c8ba533a10c68b97a0e10 Mon Sep 17 00:00:00 2001 From: grok-reviewer-andresmgsl Date: Wed, 29 Jul 2026 15:22:49 +0000 Subject: [PATCH 7/8] fix: BOX_MANUAL is pasteable try:/or: lines, not prose shell codex REQUEST_CHANGES on !114 / #125: multi-candidate recovery was rendered as `curl A | bash; if that 404s: curl B | bash`, which `bash -n` rejects (exit 2). Operators on RIG_SKIP_BOX_INSTALL / no-curl / all-candidates-failed paths were handed non-executable recovery. - box_manual_cmd / box_manual_text: one pasteable command per candidate - display as separate `try:` / `or:` lines (newlines, no prose join) - post-probe rewrite still collapses to the URL that worked - execution tests: both Forgejo URLs visible; each command passes bash -n --- commands/bootstrap.sh | 52 +++++++++++++++++++++++++++++-------------- test/cli.sh | 52 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index c7cce46..14cf9d4 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -747,25 +747,43 @@ if [ "$HOST" = "yes" ]; then printf '%s/%s/raw/branch/%s/install.sh\n' "$BOX_HOST" "$BOX_REPO" "$BOX_REF" ;; esac } - # Operator-facing recovery command. On multi-candidate hosts (Forgejo) - # list every URL — taking only the first would hand BOX_REF=main a - # /raw/tag/main URL that 404s (#111 review: "tells the operator to run a - # command that 404s"). After a live probe succeeds, the install loop - # rewrites BOX_MANUAL to the URL that actually worked. + # 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). Display uses separate prefixed lines + # (try: / or:); after a live probe succeeds the install loop rewrites + # BOX_MANUAL to the single URL that worked. + box_manual_cmd() { # box_manual_cmd — 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 + while IFS= read -r _url; 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 < <(box_install_urls) + } BOX_INSTALL_URL="$(box_install_urls | head -n1)" - BOX_MANUAL="" - while IFS= read -r _box_manual_url; do - _box_manual_cmd="curl -fsSL ${_box_manual_url} | BOX_YES=1 BOX_REF=${BOX_REF} bash" - if [ -z "$BOX_MANUAL" ]; then - BOX_MANUAL="$_box_manual_cmd" - else - BOX_MANUAL="${BOX_MANUAL}; if that 404s: ${_box_manual_cmd}" - fi - done < <(box_install_urls) + # Newline-separated try:/or: lines — each command after the prefix is + # independently pasteable (never one shell-looking string with prose). + BOX_MANUAL="$(box_manual_text)" 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:" + while IFS= read -r _line; do [ -n "$_line" ] && log " ${_line}"; done </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:" + while IFS= read -r _line; do [ -n "$_line" ] && warn " ${_line}"; done < "$BIM" +check "bootstrap: box_manual helpers extracted" 0 "try:" 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 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" +} +check "bootstrap: BOX_MANUAL GitHub is a single try: raw.githubusercontent.com line" 0 \ + "try: 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" rm -rf "$BIU_DIR" # Opt-out for rehearsals / offline / hand-managed hosts. check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \ From a3ec47113ad7ee86be32447b410101cc8a5ed8ba Mon Sep 17 00:00:00 2001 From: grok-reviewer-andresmgsl Date: Wed, 29 Jul 2026 22:38:32 +0000 Subject: [PATCH 8/8] fix: emit BOX_MANUAL line-by-line; bare command for single candidate claude REQUEST_CHANGES on 1c9a245: the all-candidates-fail path still interpolated multi-line BOX_MANUAL into one warn, orphaning the or: line and leaving try: non-pasteable on the default host. - box_manual_emit log|warn prints one recovery line at a time - single-candidate hosts (GitHub) get a bare pasteable command (no try:) - multi-candidate keeps try:/or: prefixes - never ${BOX_MANUAL} inside a log/warn string - cli.sh asserts the emission invariant --- commands/bootstrap.sh | 57 +++++++++++++++++++++++++++++++------------ test/cli.sh | 25 +++++++++++++------ 2 files changed, 59 insertions(+), 23 deletions(-) diff --git a/commands/bootstrap.sh b/commands/bootstrap.sh index 14cf9d4..43f06ef 100755 --- a/commands/bootstrap.sh +++ b/commands/bootstrap.sh @@ -751,15 +751,31 @@ if [ "$HOST" = "yes" ]; then # 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). Display uses separate prefixed lines - # (try: / or:); after a live probe succeeds the install loop rewrites - # BOX_MANUAL to the single URL that worked. + # 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 — 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 + 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)) @@ -768,22 +784,28 @@ if [ "$HOST" = "yes" ]; then else printf 'or: %s\n' "$_cmd" fi - done < <(box_install_urls) + 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 </dev/null 2>&1; then warn "curl not found — skipping box install; once curl is present, prepare Incus with:" - while IFS= read -r _line; do [ -n "$_line" ] && warn " ${_line}"; done </dev/null 2>&1; then log "box installed and host set up — 'box doctor' passed; mint guest boxes with 'box new'" 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 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 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 rm -f "$BOX_SCRIPT" fi diff --git a/test/cli.sh b/test/cli.sh index f1b19a0..0f68bf3 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -303,23 +303,25 @@ check "bootstrap: box_install_urls Forgejo branch second" 0 \ 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: separate try:/or: lines, each command pasteable -# (codex REQUEST_CHANGES / #125 — prose-joined strings fail `bash -n`). -# Extract box_manual_cmd + box_manual_text with the same nested-fn idiom. +# 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 "try:" cat "$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 try:/or: passes bash -n +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" @@ -332,8 +334,9 @@ bmanual_cmds_ok() { # every pasteable command after try:/or: passes bash -n bash -n <<<"$cmd" done < <(box_manual_text)' _ "$BIM" } -check "bootstrap: BOX_MANUAL GitHub is a single try: raw.githubusercontent.com line" 0 \ - "try: curl -fsSL https://raw.githubusercontent.com/heavy-duty/box/0.9.0/install.sh | BOX_YES=1 BOX_REF=0.9.0 bash" \ +# 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" \ @@ -348,6 +351,14 @@ check "bootstrap: BOX_MANUAL Forgejo commands pass bash -n" 0 "" \ # 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. check "bootstrap: box install honors RIG_SKIP_BOX_INSTALL opt-out" 0 "" \