From b6205d81c597d680b24020da3e70bcdd87dacac0 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 20:57:13 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(release):=20CHANGELOG.md=20and=20relea?= =?UTF-8?q?se.yml=20=E2=80=94=20a=20tag=20becomes=20the=20release=20it=20c?= =?UTF-8?q?laims=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR-then-tag half of box#83's flow, rig's side. CHANGELOG.md starts with an Unreleased section (feature PRs land their entry as part of the PR — box's convention, now written into CONTRIBUTING alongside the release ritual). On a tag push, release.yml asserts the bare tag equals the tree's own VERSION — a mismatch fails loudly and creates nothing — then creates the GitHub release with that version's changelog section as the body, extracted by changelog_section in .github/scripts/release-lib.sh: one function, sourced by the workflow and driven by test/release.sh against fixtures and the shipped CHANGELOG.md itself. No assets — for a pure-bash tree, the tag's source tarball IS the package. Co-Authored-By: Claude Fable 5 --- .github/scripts/release-lib.sh | 23 +++ .github/workflows/ci.yml | 2 + .github/workflows/release.yml | 48 ++++++ CHANGELOG.md | 31 ++++ CONTRIBUTING.md | 26 +++- test/release.sh | 260 +++++++++++++++++++++++++++++++++ 6 files changed, 387 insertions(+), 3 deletions(-) create mode 100644 .github/scripts/release-lib.sh create mode 100644 .github/workflows/release.yml create mode 100644 CHANGELOG.md create mode 100644 test/release.sh diff --git a/.github/scripts/release-lib.sh b/.github/scripts/release-lib.sh new file mode 100644 index 0000000..aaddc11 --- /dev/null +++ b/.github/scripts/release-lib.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Release plumbing shared by .github/workflows/release.yml and the test +# harness (test/release.sh) — pure functions, sourced, never executed on +# their own (repo precedent: labels-reconcile.sh's decide_state, the +# commands/lib/*.sh parsers). + +# changelog_section +# +# Print the BODY of that version's CHANGELOG.md section: everything between +# its heading and the next '## ' heading (or EOF). A release heading is +# stamped '## ' and the Unreleased one is bare +# '## Unreleased'; the second field is the version either way, so both +# shapes match. The heading itself is not printed — the release title +# already names the version — and leading blank lines are dropped. Empty +# output means "no such section", which release.yml turns into a refusal: a +# tag with no changelog entry must not ship an empty release. +changelog_section() { + awk -v ver="$2" ' + /^## / { if (found) exit; found = ($2 == ver); next } + found && !body && /^[[:space:]]*$/ { next } + found { body = 1; print } + ' "$1" +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40454c5..d0731ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,8 @@ jobs: shellcheck -x "${files[@]}" - name: cli tests run: bash test/cli.sh + - name: release-flow tests + run: bash test/release.sh # Kept SEPARATE from `check` on purpose: this job pulls a Postgres image and # stands up throwaway containers, and a slow image pull must never delay the diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e9ed7a5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: release +# The tag half of the release flow (#32; box#83's design, near-verbatim). +# A release is a PR, then a tag: the `release: X.Y.Z` PR bumps VERSION and +# stamps CHANGELOG.md's Unreleased section with version + date; after the +# merge, the merge commit is tagged bare `X.Y.Z` (no `v` prefix — box's tag +# scheme) and the tag is pushed. This workflow turns that tag into the +# GitHub release, with the changelog section as the body — the curated +# prose, never the auto-generated PR list. +# +# No assets on purpose: for a pure-bash tree, GitHub's source tarball for +# the tag IS the package (install.sh downloads archive/refs/tags/). +on: + push: + # Every tag, not a shape filter: a tag that mismatches VERSION must fail + # LOUDLY below, not be silently skipped by a pattern that didn't match. + tags: ['**'] + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + # The tag names a tree; the tree names its own version. When they + # disagree, creating a release would put a version label on a tree + # that is not that version — exactly the lie the release flow exists + # to end — so: fail, create nothing. + - name: assert the tag matches the tree's VERSION + run: | + ver="$(cat VERSION)" + if [ "$GITHUB_REF_NAME" != "$ver" ]; then + echo "tag '$GITHUB_REF_NAME' != VERSION '$ver' — refusing to create a release for a tree that says it is something else" >&2 + exit 1 + fi + - name: create the release from the changelog section + env: + GH_TOKEN: ${{ github.token }} + run: | + . .github/scripts/release-lib.sh + notes="$(changelog_section CHANGELOG.md "$GITHUB_REF_NAME")" + if [ -z "$notes" ]; then + echo "CHANGELOG.md has no '## $GITHUB_REF_NAME' section — stamp the Unreleased section in the release PR before tagging" >&2 + exit 1 + fi + gh release create "$GITHUB_REF_NAME" --verify-tag \ + --title "$GITHUB_REF_NAME" --notes "$notes" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..27528f1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +History before 0.1.0 lives in git — rig grew its version surface (`VERSION`, +`rig --version`, the side-by-side `versions/` install layout; #35/#36) +on the way to cutting its first release, and this file starts there. + +## Unreleased + +### Added + +- **Tagged releases, and an installer that installs them** (#32) — the rig + half of the flow designed in heavy-duty/box#83, near-verbatim. A release + is a PR, then a tag: the `release: X.Y.Z` PR bumps `VERSION` and stamps + this file's Unreleased section with version + date; the merge commit is + tagged bare `X.Y.Z` (box's tag scheme — no `v` prefix). `release.yml` + turns the tag into the GitHub release — after asserting tag == `VERSION` + (mismatch fails loudly and creates nothing) — with that version's section + of this file as the body, extracted by the same `changelog_section` the + test harness drives. No assets: for a pure-bash tree, GitHub's source + tarball for the tag IS the package. `install.sh` now defaults to the + **latest release**: the tag is resolved by following the + `releases/latest` redirect and reading the `Location` header — no API, no + token — and the download is `archive/refs/tags/.tar.gz`. `RIG_REF` + picks the other two channels: a tag pins (`refs/tags` outranks a + same-named branch), a branch (`RIG_REF=main`) tracks the development + tree. Until 0.1.0 is cut the default channel has nothing to resolve and + dies saying exactly that, naming `RIG_REF=main` as the way to install + today — it never falls back to main silently, because "I installed the + latest release" must not quietly mean "I installed whatever main was that + second". Step 5 of #32 — pinning `BOX_REF` in the host-installs-box path + — stays open until box cuts its next tagged release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15e1376..36701e5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,9 +36,29 @@ labels tell you where everything is without opening anything. With three formal head-current approvals the labels workflow requests it automatically; when part of the panel is comment-only, reading their agreement is the author's judgment, so the author makes the request. -7. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally - mirror what CI runs; the db dump/restore round-trip - (`test/db-integration.sh`) executes in CI where Docker is present. +7. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and + `bash test/release.sh` locally mirror what CI runs; the db dump/restore + round-trip (`test/db-integration.sh`) executes in CI where Docker is + present. +8. **Feature PRs land their changelog entry as part of the PR** (box's + convention): add it under `CHANGELOG.md`'s `## Unreleased` heading — + that section becomes the release notes verbatim when a release is cut. + +## Releasing + +A release is a PR, then a tag (#32; box#83's design): + +1. A small PR — `release: X.Y.Z` — bumps `VERSION` from `X.Y.Z-dev` and + stamps `CHANGELOG.md`'s Unreleased section as `## X.Y.Z — YYYY-MM-DD`. + CI green on it, same loop as any PR. +2. Merge, tag the merge commit bare `X.Y.Z` (no `v` prefix — box's tag + scheme), push the tag. `release.yml` asserts tag == `VERSION` (a + mismatch fails loudly and creates nothing) and creates the GitHub + release with that version's changelog section as the body. No assets — + the source tarball for the tag is the package `install.sh` downloads. +3. A follow-up (or the next feature PR) bumps main's `VERSION` to + `X.Y.(Z+1)-dev`, so a dev install never impersonates the release in the + `versions/` layout. ## Labels — who sets what diff --git a/test/release.sh b/test/release.sh new file mode 100644 index 0000000..7e3ead3 --- /dev/null +++ b/test/release.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# The release flow's testable half (#32): changelog extraction, latest-tag +# resolution, and the installer's three channels. Dependency-free and +# NETWORK-FREE — wherever the code under test would call curl, the curl on +# PATH is a stub this harness wrote. Run: bash test/release.sh +# Deliberately no `set -e` — the harness asserts on failing commands. +set -u +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" || exit 1 +# The extraction the workflow runs is the extraction under test — one +# function, sourced by release.yml and by this harness (repo precedent: +# test/labels-reconcile.sh sourcing the reconciler's decide_state). +# shellcheck source=.github/scripts/release-lib.sh +. "$ROOT/.github/scripts/release-lib.sh" +PASS=0 FAIL=0 + +# check +# Runs cmd, asserts exit code and (if non-empty) that combined output +# contains want_substr. +check() { + local desc="$1" want="$2" substr="$3"; shift 3 + local out rc + out="$("$@" 2>&1)"; rc=$? + if [ "$rc" -ne "$want" ]; then + echo "FAIL: $desc — exit $rc, wanted $want" + printf '%s\n' "$out" | sed 's/^/ /' + FAIL=$((FAIL + 1)); return + fi + if [ -n "$substr" ] && ! printf '%s' "$out" | grep -qF -e "$substr"; then + echo "FAIL: $desc — output missing '$substr'" + printf '%s\n' "$out" | sed 's/^/ /' + FAIL=$((FAIL + 1)); return + fi + echo "ok: $desc"; PASS=$((PASS + 1)) +} + +WORK="$(mktemp -d)" +FAKEHOME="$WORK/home"; mkdir -p "$FAKEHOME" + +# --- changelog_section: the release body, extracted -------------------------- +# A fixture changelog with the three heading shapes the flow produces: the +# bare '## Unreleased', stamped '## X.Y.Z — date' releases, and a last +# section that runs to EOF. +FIXCH="$WORK/CHANGELOG.fixture.md" +cat > "$FIXCH" <<'MD' +# Changelog + +History before 0.1.0 lives in git. + +## Unreleased + +- an unreleased entry + +## 0.2.0 — 2026-07-18 + +### Added + +- **the newer entry** (#42) — prose. + +### Fixed + +- a fix in 0.2.0 + +## 0.1.0 — 2026-07-01 + +- the first entry +MD +sect_has() { changelog_section "$1" "$2" | grep -qF -e "$3"; } +check "changelog: extracts the asked-for section" 0 "the newer entry" \ + changelog_section "$FIXCH" 0.2.0 +check "changelog: the whole section, subheadings included" 0 "a fix in 0.2.0" \ + changelog_section "$FIXCH" 0.2.0 +check "changelog: stops at the next release heading" 1 "" \ + sect_has "$FIXCH" 0.2.0 "the first entry" +check "changelog: never leaks the preceding section" 1 "" \ + sect_has "$FIXCH" 0.2.0 "an unreleased entry" +check "changelog: the heading itself is not the body" 1 "" \ + sect_has "$FIXCH" 0.2.0 "## 0.2.0" +first_line() { changelog_section "$1" "$2" | head -n1; } +check "changelog: leading blank lines are dropped" 0 "### Added" \ + first_line "$FIXCH" 0.2.0 +check "changelog: the bare Unreleased heading matches too" 0 "an unreleased entry" \ + changelog_section "$FIXCH" Unreleased +check "changelog: the last section runs to EOF" 0 "the first entry" \ + changelog_section "$FIXCH" 0.1.0 +absent() { [ -z "$(changelog_section "$1" "$2")" ]; } +check "changelog: an unknown version yields NOTHING (the refusal signal)" 0 "" \ + absent "$FIXCH" 3.3.3 +check "changelog: a date-stamped heading never matches by date" 0 "" \ + absent "$FIXCH" 2026-07-18 + +# ...and the SHIPPED changelog fits the extractor: an Unreleased section the +# release PR will stamp, extractable by the exact function release.yml runs. +check "CHANGELOG.md: has an Unreleased section" 0 "" \ + grep -qx "## Unreleased" "$ROOT/CHANGELOG.md" +check "CHANGELOG.md: Unreleased extracts non-empty (the format fits the tool)" \ + 0 "#32" changelog_section "$ROOT/CHANGELOG.md" Unreleased + +# --- release.yml: the pins --------------------------------------------------- +# The workflow itself runs only on a tag push upstream, so pin its +# load-bearing pieces the way the harness pins root-only paths (repo +# precedent: the tag-refusal greps in test/cli.sh). +RY="$ROOT/.github/workflows/release.yml" +check "release.yml: exists" 0 "" test -f "$RY" +check "release.yml: triggers on tag pushes" 0 "" grep -q "tags:" "$RY" +check "release.yml: sources the shared lib (one extractor, not a copy)" 0 "" \ + grep -q "release-lib.sh" "$RY" +check "release.yml: the body comes from changelog_section" 0 "" \ + grep -q "changelog_section CHANGELOG.md" "$RY" +check "release.yml: a tag/VERSION mismatch refuses to create" 0 "" \ + grep -q "refusing to create a release" "$RY" +check "release.yml: an empty changelog section refuses too" 0 "" \ + grep -q "has no '## " "$RY" +check "release.yml: gh release create verifies the tag" 0 "" \ + grep -q -- "--verify-tag" "$RY" +# Ordering: the mismatch assert must precede the create (line compare, the +# repo's marker-then-box idiom; defaults fail closed). +assert_at="$(grep -n "refusing to create a release" "$RY" | head -n1 | cut -d: -f1)" +create_at="$(grep -n "gh release create" "$RY" | head -n1 | cut -d: -f1)" +check "release.yml: the assert precedes the create" \ + 0 "" test "${assert_at:-999999}" -lt "${create_at:-0}" + +# --- the installer's ref logic, extracted ------------------------------------ +# install.sh must stay a single curl|bash file, so its channel functions live +# 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" +check "installer fns extracted (guards the awk)" 0 "redirect_url" cat "$RL" + +STUB="$WORK/stub"; mkdir -p "$STUB" +cat > "$STUB/curl" <<'CURL' +#!/usr/bin/env bash +# The harness's curl — never the network. Scripted via env: +# CURL_STUB_FAIL nonempty -> every call exits 22 (curl's HTTP error) +# CURL_STUB_REDIRECT what -w %{redirect_url} answers (the HEAD probe) +# CURL_STUB_OK substring a download URL must carry to succeed +# CURL_STUB_TARBALL copied to -o's target on a successful download +# CURL_STUB_LOG every URL asked for, one per line, appended +set -u +out="" url="" probe=0 +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -w) probe=1; shift 2 ;; + -*) shift ;; + *) url="$1"; shift ;; + esac +done +if [ -n "${CURL_STUB_LOG:-}" ]; then printf '%s\n' "$url" >> "$CURL_STUB_LOG"; fi +if [ -n "${CURL_STUB_FAIL:-}" ]; then exit 22; fi +if [ "$probe" -eq 1 ]; then printf '%s' "${CURL_STUB_REDIRECT:-}"; exit 0; fi +case "$url" in + *"${CURL_STUB_OK:-/__nothing_succeeds__/}"*) cp "${CURL_STUB_TARBALL:?}" "${out:?}"; exit 0 ;; + *) exit 22 ;; +esac +CURL +chmod +x "$STUB/curl" + +rlt() { # rlt [VAR=val ...] — resolve_latest_tag under the stub curl + # The single-quoted $1 is the inner bash's positional, not this shell's. + # shellcheck disable=SC2016 + env PATH="$STUB:$PATH" "$@" bash -c 'set -euo pipefail + . "$1"; resolve_latest_tag heavy-duty/rig' _ "$RL" +} +check "resolve: a releases/tag redirect yields the tag" 0 "0.1.0" \ + rlt CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/0.1.0 +# A repo with NO releases redirects to /releases (measured live against +# heavy-duty/rig itself) — that must fail, never invent a ref. +check "resolve: the no-releases redirect (/releases) fails" 1 "" \ + rlt CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases +check "resolve: no redirect at all fails" 1 "" rlt +check "resolve: a tagless releases/tag/ redirect fails" 1 "" \ + rlt CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/ +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" +} +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 + +# --- the three channels, driven through the REAL installer ------------------- +# Full install.sh runs against throwaway roots with the stub curl on PATH: the +# channel selection, the tag-first fallback, and the loud no-releases refusal +# are all DRIVEN, not grepped (the test/cli.sh install-drill idiom). +TBDIR="$WORK/tb"; mkdir -p "$TBDIR/rig-7.7.7-relflow/bin" +cp "$ROOT/bin/rig" "$TBDIR/rig-7.7.7-relflow/bin/rig" +chmod +x "$TBDIR/rig-7.7.7-relflow/bin/rig" +echo "7.7.7-relflow" > "$TBDIR/rig-7.7.7-relflow/VERSION" +tar -C "$TBDIR" -czf "$WORK/release.tgz" rig-7.7.7-relflow + +rinst() { # rinst [VAR=val ...] — a real install.sh run, stubbed net + local h="$1" b="$2"; shift 2 + env -u RIG_REF PATH="$STUB:$PATH" HOME="$FAKEHOME" \ + RIG_ROLE_MARKER="$WORK/no-marker" RIG_HOME="$h" RIG_BIN="$b" \ + CURL_STUB_TARBALL="$WORK/release.tgz" "$@" bash "$ROOT/install.sh" +} + +# Channel 1 — RIG_REF unset, a release exists: resolve the tag, download +# refs/tags/, and the installed tree records exactly that ref. +H1="$WORK/h1"; B1="$WORK/b1" +check "channel latest: resolves and installs the release tag" 0 "done" \ + rinst "$H1" "$B1" \ + CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases/tag/7.7.7-relflow \ + CURL_STUB_OK=refs/tags/7.7.7-relflow +check "channel latest: the tree landed under the tag's version" 0 "" \ + test -x "$H1/versions/7.7.7-relflow/bin/rig" +check "channel latest: INSTALLED_FROM names the resolved tag" 0 \ + "heavy-duty/rig@7.7.7-relflow" cat "$H1/versions/7.7.7-relflow/INSTALLED_FROM" + +# Channel 1, transitional — RIG_REF unset, NO release exists (rig today): +# fail LOUDLY, name RIG_REF=main as the way out, install nothing. The stub +# would happily serve refs/heads/main here — a silent fallback would pass the +# download and FAIL this check by succeeding. +H2="$WORK/h2"; B2="$WORK/b2" +check "channel latest: no releases yet — dies, never hangs, never falls back" \ + 1 "RIG_REF=main" rinst "$H2" "$B2" \ + CURL_STUB_REDIRECT=https://github.com/heavy-duty/rig/releases \ + CURL_STUB_OK=refs/heads/main +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 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" +check "channel pinned: RIG_REF= installs from refs/tags" 0 "refs/tags/7.7.7-relflow" \ + rinst "$H3" "$B3" RIG_REF=7.7.7-relflow \ + CURL_STUB_OK=refs/tags/7.7.7-relflow CURL_STUB_LOG="$LOG3" +check "channel pinned: no releases/latest probe for an explicit ref" 1 "" \ + grep -q "releases/latest" "$LOG3" +check "channel pinned: exactly one download (the tag hit first)" 0 "1" \ + grep -c . "$LOG3" + +# Channel 3 — RIG_REF=: the tag candidate misses, refs/heads lands. +H4="$WORK/h4"; B4="$WORK/b4"; LOG4="$WORK/log4" +check "channel dev: a branch ref falls back to refs/heads" 0 "done" \ + rinst "$H4" "$B4" RIG_REF=feature-x \ + CURL_STUB_OK=refs/heads/feature-x CURL_STUB_LOG="$LOG4" +check "channel dev: the tag URL was still tried FIRST" 0 "refs/tags/feature-x" \ + sed -n 1p "$LOG4" +check "channel dev: ...then the branch URL" 0 "refs/heads/feature-x" \ + sed -n 2p "$LOG4" + +# 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 + +rm -rf "$WORK" + +echo "---" +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] From 834f0504d65a979fce9bb743bb8ee6223e498d41 Mon Sep 17 00:00:00 2001 From: dan-claude-bot Date: Sat, 18 Jul 2026 20:57:13 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(install):=20default=20to=20the=20lates?= =?UTF-8?q?t=20release=20=E2=80=94=20three=20channels,=20one=20installer?= =?UTF-8?q?=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RIG_REF unset now means the latest RELEASE: the tag is resolved by following the releases/latest redirect and reading the Location header (no API, no token), and the download is archive/refs/tags/.tar.gz. An explicit RIG_REF tries refs/tags first — a pin must outrank a branch that shares its name — and falls back to refs/heads, which keeps RIG_REF=main the dev channel. "Re-run any time to upgrade" now means upgrade to the latest release. Transitional, and loud about it: rig has no release until 0.1.0 is cut, so the default channel dies naming RIG_REF=main as the way to install today — never a silent fallback to main, because "I installed the latest release" must not quietly mean "I installed whatever main was that second". test/release.sh drives all three channels through the real install.sh with a stubbed curl (no network in CI), and the resolve/ candidate functions are extracted and exercised the valid_version way. Co-Authored-By: Claude Fable 5 --- README.md | 39 +++++++++++++++++++++++------- bin/rig | 6 +++-- install.sh | 71 +++++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e1c2cdf..ea16bdd 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,26 @@ takes arguments, does its work, and stores no credential, ever. curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash ``` +That installs the **latest release**: the installer resolves the newest tag +by following GitHub's `releases/latest` redirect (no API, no token) and +downloads that tag's source tarball — which, for a pure-bash tree, *is* the +package. Three channels from the same script; `RIG_REF` picks: + +```sh +curl -fsSL .../install.sh | bash # the latest release +curl -fsSL .../install.sh | RIG_REF=0.1.0 bash # pinned to a release +curl -fsSL .../install.sh | RIG_REF=main bash # the development tree +``` + +A tag outranks a branch of the same name (the pin must win); anything that +is not a tag falls back to `refs/heads/`. + +> **Transitional, until 0.1.0 is cut** (right after rig#32 lands): rig has +> no GitHub release yet, so the default channel has nothing to resolve — +> it **fails loudly** naming `RIG_REF=main` as the way to install today, +> and never silently falls back to main. Once 0.1.0 exists, the plain +> `curl | bash` above is the normal path. + The layout, under the install root (`~/.local/share/rig`): ``` @@ -30,8 +50,8 @@ $BINDIR/rig -> current/bin/rig the PATH entry, riding the chain **Re-running is a safe converge.** Installing a version you already have changes nothing and says so (`RIG_REINSTALL=1` replaces that version's tree); a **new** version installs side by side and becomes the default — so -"re-run any time to upgrade" stays true, and every version you had stays -installed as the way back: +"re-run any time to upgrade" stays true, and now means *upgrade to the +latest release*; every version you had stays installed as the way back: ```sh rig versions # what is installed, which is current, which is running @@ -337,13 +357,14 @@ command is exactly who that refusal catches (it names the new spelling). > **The rig install in the seed is unpinned — same honesty as the box note > above.** The seed preinstalls rig via its curl installer, which resolves -> `RIG_REPO`/`RIG_REF` (default `heavy-duty/rig@main`, branches only — rig -> cuts no tags yet). That inverts the install edge on this page: rig installs -> box on host-class machines, and box guests now install rig — both tracking -> a moving `main` until a release flow exists (rig#32). `RIG_REPO`/`RIG_REF` -> are the pin points the day there is something to pin to, or point them at a -> frozen branch of your own fork. The seed side of this edge is box#81's to -> document. +> `RIG_REPO`/`RIG_REF` — and since rig#32 the installer defaults to the +> **latest release**, with `RIG_REF=` the pin and `RIG_REF=main` the +> dev channel. Until rig cuts 0.1.0 there is no release to resolve, so the +> seed must set `RIG_REF=main` explicitly (the default channel fails loudly +> rather than falling back). That inverts the install edge on this page: +> rig installs box on host-class machines, and box guests now install rig. +> `RIG_REPO`/`RIG_REF` are the pin points, or point them at a frozen branch +> of your own fork. The seed side of this edge is box#81's to document. ### The identity model diff --git a/bin/rig b/bin/rig index 011b7f1..98a2190 100755 --- a/bin/rig +++ b/bin/rig @@ -95,8 +95,10 @@ commands: install/upgrade: curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash - 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 (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. EOF } diff --git a/install.sh b/install.sh index ad00104..6e97110 100644 --- a/install.sh +++ b/install.sh @@ -3,6 +3,15 @@ set -euo pipefail # rig installer — intended for: curl -fsSL .../install.sh | bash # +# Three channels from this one script (heavy-duty/rig#32; box#83's design): +# +# RIG_REF unset the latest RELEASE — the tag is resolved from the +# releases/latest redirect, the download is that tag's +# source tarball (which IS the package) +# RIG_REF= that release, pinned (a tag outranks a branch of the +# same name) +# RIG_REF= the development tree, e.g. RIG_REF=main +# # Downloads the rig repo tarball and installs it into the VERSIONED layout # under $DEST (box#79's layout, ported — heavy-duty/rig#35): # @@ -25,7 +34,7 @@ set -euo pipefail # review. REPO="${RIG_REPO:-heavy-duty/rig}" -REF="${RIG_REF:-main}" +REF="${RIG_REF:-}" # empty = the latest release, resolved below DEST="${RIG_HOME:-$HOME/.local/share/rig}" if [ "$(id -u)" -eq 0 ]; then BINDIR="${RIG_BIN:-/usr/local/bin}" @@ -67,6 +76,32 @@ warn_bootstrapped() { # $1 = what is about to happen warn "$1 changes what a re-converge (rig bootstrap, users apply) would do — proceeding." } +# --- the release channels (#32; box#83's design, near-verbatim) -------------- +# resolve_latest_tag — print the latest RELEASE tag, resolved by +# following the releases/latest redirect and reading the Location header +# (curl's %{redirect_url} is that header, parsed): no API, no token, no +# rate-limit pain. A repo with no releases redirects to /releases — not to +# /releases/tag/ — 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. +resolve_latest_tag() { + local loc + loc="$(curl -fsSI -o /dev/null -w '%{redirect_url}' "https://github.com/$1/releases/latest")" || return 1 + case "$loc" in + */releases/tag/?*) printf '%s\n' "${loc##*/releases/tag/}" ;; + *) return 1 ;; + esac +} + +# 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. +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" +} + # --- prerequisites ----------------------------------------------------------- # curl only when something must be downloaded — a local RIG_INSTALL_SOURCE # needs none, which is what lets test/cli.sh drive REAL installs offline. @@ -78,7 +113,7 @@ command -v tar >/dev/null 2>&1 || die "tar is required but was not found." if [ -n "${RIG_INSTALL_SOURCE:-}" ]; then SRCDESC="local source $RIG_INSTALL_SOURCE" else - SRCDESC="$REPO@$REF" + SRCDESC="$REPO@${REF:-latest-release}" # refined once the tag resolves fi # Flip $DEST/current to versions/ atomically: build the new link beside it, @@ -138,12 +173,36 @@ if [ -n "${RIG_INSTALL_SOURCE:-}" ]; then die "RIG_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC" fi else + # Which ref? RIG_REF unset means the latest release — and while no release + # exists (rig cuts its first, 0.1.0, right after #32 lands), that channel + # must FAIL, loudly and with the way out, never silently fall back to + # main: "I installed the latest release" must not quietly mean "I + # installed whatever main was that second". + if [ -z "$REF" ]; then + log "resolving the latest release of $REPO" + if ! REF="$(resolve_latest_tag "$REPO")"; then + 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" + fi + log "latest release: $REF" + urls=("https://github.com/$REPO/archive/refs/tags/$REF.tar.gz") + else + mapfile -t urls < <(ref_candidate_urls "$REPO" "$REF") + fi + SRCDESC="$REPO@$REF" INSTALLED_FROM="$REPO@$REF" - URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz" log "installing rig ($REPO@$REF)" - log "downloading $URL" - curl -fsSL "$URL" -o "$TMPDIR/rig.tar.gz" \ - || die "failed to download $URL" + got="" + for URL in "${urls[@]}"; do + log "downloading $URL" + if curl -fsSL "$URL" -o "$TMPDIR/rig.tar.gz"; then + got="$URL" + break + fi + done + [ -n "$got" ] \ + || die "failed to download $REPO@$REF — not a tag and not a branch (tried refs/tags then refs/heads)" log "extracting archive" tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \