feat: release flow — CHANGELOG, release.yml, and a tag-resolving installer (#32) #40

Merged
dan-claude-bot merged 2 commits from feat/release-flow into main 2026-07-18 22:17:13 +00:00
dan-claude-bot commented 2026-07-18 20:57:43 +00:00 (Migrated from github.com)

Part of #32 (the remaining scope after #36 landed VERSION, rig --version, and the versions/<v> layout). Not a close: cutting 0.1.0 and the BOX_REF pin still follow.

The problem

rig can name the version it runs (#36) but not release one: no CHANGELOG.md, no tags, no GitHub releases — and install.sh only knows refs/heads/<branch>, so "what rig is this host running?" still bottoms out at "whatever main was when it last curl'd". This PR is the rig half of the flow designed in heavy-duty/box#83, near-verbatim.

What lands

  • CHANGELOG.md — box's shape: history before 0.1.0 lives in git, an ## Unreleased section that feature PRs append to as part of the PR (convention now written into CONTRIBUTING, alongside the release ritual: a release: X.Y.Z PR stamps the section and bumps VERSION, then a bare X.Y.Z tag on the merge commit — no v prefix, box's tag scheme).

  • .github/workflows/release.yml — on any tag push: assert tag == VERSION (a mismatch fails loudly and creates nothing), then gh release create --verify-tag with that version's changelog section as the body. The section is extracted by changelog_section in .github/scripts/release-lib.sh — one function, sourced by the workflow and by the tests. No assets: for a pure-bash tree, the tag's source tarball IS the package.

  • The tag-resolving installer — three channels from one script:

    • RIG_REF unset → the latest release: the tag resolved from the releases/latest redirect's Location header (no API, no token), downloaded as archive/refs/tags/<tag>.tar.gz;
    • RIG_REF=<tag> → pinned (refs/tags tried first, so a tag outranks a same-named branch);
    • RIG_REF=<branch> → the dev channel (refs/heads fallback).

    README documents all three; "re-run any time to upgrade" now means upgrade to the latest release.

Transitional: rig has no GitHub release yet — 0.1.0 is cut right after this merges. Until then the default channel has nothing to resolve and fails loudly, naming RIG_REF=main as the way to install today. It never silently falls back to main: "I installed the latest release" must not quietly mean "I installed whatever main was that second". (Measured live: a repo with no releases redirects releases/latest to /releases, not to /releases/tag/<tag> — the failure is detectable, never a hang.)

Tests

test/release.sh (41 checks, wired into ci.yml's check job; network-free — curl is a harness-written stub on PATH):

  • changelog_section driven against fixtures (all three heading shapes, section boundaries, the empty-output refusal signal) and against the shipped CHANGELOG.md itself, via the exact sourced function release.yml runs;
  • resolve_latest_tag / ref_candidate_urls extracted from install.sh (the valid_version awk idiom) and driven: the tag redirect, the measured no-releases /releases redirect, no redirect, a failing curl;
  • all three channels driven through the real install.sh against throwaway roots: latest-release resolve+install, the no-releases refusal (with the stub deliberately willing to serve refs/heads/main — a silent fallback would make that check fail by succeeding), tag-first ordering, branch fallback, and the neither-tag-nor-branch die;
  • release.yml's load-bearing pieces grep-pinned (assert-precedes-create ordering included), the repo's idiom for paths a harness cannot execute.

How verified

  • shellcheck with ci.yml's exact invocation (globstar bin/* **/*.sh): clean.
  • bash test/cli.sh: 318/318. bash test/release.sh: 41/41. bash test/labels-reconcile.sh: 19/19. bash test/db-integration.sh (Docker present locally): 12/12.
  • Live network: default channel against heavy-duty/rig → the loud no-releases die (exit 1, names RIG_REF=main); RIG_REF=main → real install (tries refs/tags/main, 404s, lands refs/heads/main, rig --version answers 0.1.0-dev through the chain); default channel with RIG_REPO=heavy-duty/box → resolves 0.6.0 from the redirect and downloads the real refs/tags/0.6.0.tar.gz (then dies at the bin/rig tree check, as it should — it's box's tree).

Deferred

Step 5 of #32 — pinning BOX_REF in the host-installs-box path — waits for box's next tagged release (its side of heavy-duty/box#83) and lands as a follow-up.

🤖 Generated with Claude Code

Part of #32 (the remaining scope after #36 landed `VERSION`, `rig --version`, and the `versions/<v>` layout). Not a close: cutting 0.1.0 and the `BOX_REF` pin still follow. ## The problem rig can name the version it runs (#36) but not release one: no `CHANGELOG.md`, no tags, no GitHub releases — and `install.sh` only knows `refs/heads/<branch>`, so "what rig is this host running?" still bottoms out at "whatever main was when it last curl'd". This PR is the rig half of the flow designed in heavy-duty/box#83, near-verbatim. ## What lands - **`CHANGELOG.md`** — box's shape: history before 0.1.0 lives in git, an `## Unreleased` section that feature PRs append to as part of the PR (convention now written into CONTRIBUTING, alongside the release ritual: a `release: X.Y.Z` PR stamps the section and bumps `VERSION`, then a bare `X.Y.Z` tag on the merge commit — no `v` prefix, box's tag scheme). - **`.github/workflows/release.yml`** — on any tag push: assert tag == `VERSION` (a mismatch fails loudly and creates nothing), then `gh release create --verify-tag` with that version's changelog section as the body. The section is extracted by `changelog_section` in `.github/scripts/release-lib.sh` — one function, sourced by the workflow and by the tests. No assets: for a pure-bash tree, the tag's source tarball IS the package. - **The tag-resolving installer** — three channels from one script: - `RIG_REF` unset → the **latest release**: the tag resolved from the `releases/latest` redirect's `Location` header (no API, no token), downloaded as `archive/refs/tags/<tag>.tar.gz`; - `RIG_REF=<tag>` → pinned (`refs/tags` tried first, so a tag outranks a same-named branch); - `RIG_REF=<branch>` → the dev channel (`refs/heads` fallback). README documents all three; "re-run any time to upgrade" now means *upgrade to the latest release*. > **Transitional:** rig has no GitHub release yet — 0.1.0 is cut right after this merges. Until then the default channel has nothing to resolve and **fails loudly**, naming `RIG_REF=main` as the way to install today. It never silently falls back to main: "I installed the latest release" must not quietly mean "I installed whatever main was that second". (Measured live: a repo with no releases redirects `releases/latest` to `/releases`, not to `/releases/tag/<tag>` — the failure is detectable, never a hang.) ## Tests `test/release.sh` (41 checks, wired into ci.yml's `check` job; network-free — curl is a harness-written stub on PATH): - `changelog_section` driven against fixtures (all three heading shapes, section boundaries, the empty-output refusal signal) and against the shipped `CHANGELOG.md` itself, via the exact sourced function release.yml runs; - `resolve_latest_tag` / `ref_candidate_urls` extracted from `install.sh` (the `valid_version` awk idiom) and driven: the tag redirect, the measured no-releases `/releases` redirect, no redirect, a failing curl; - all three channels driven through the **real `install.sh`** against throwaway roots: latest-release resolve+install, the no-releases refusal (with the stub deliberately willing to serve `refs/heads/main` — a silent fallback would make that check fail by succeeding), tag-first ordering, branch fallback, and the neither-tag-nor-branch die; - release.yml's load-bearing pieces grep-pinned (assert-precedes-create ordering included), the repo's idiom for paths a harness cannot execute. ## How verified - `shellcheck` with ci.yml's exact invocation (globstar `bin/* **/*.sh`): clean. - `bash test/cli.sh`: 318/318. `bash test/release.sh`: 41/41. `bash test/labels-reconcile.sh`: 19/19. `bash test/db-integration.sh` (Docker present locally): 12/12. - **Live network**: default channel against heavy-duty/rig → the loud no-releases die (exit 1, names `RIG_REF=main`); `RIG_REF=main` → real install (tries `refs/tags/main`, 404s, lands `refs/heads/main`, `rig --version` answers `0.1.0-dev` through the chain); default channel with `RIG_REPO=heavy-duty/box` → resolves `0.6.0` from the redirect and downloads the real `refs/tags/0.6.0.tar.gz` (then dies at the `bin/rig` tree check, as it should — it's box's tree). ## Deferred Step 5 of #32 — pinning `BOX_REF` in the host-installs-box path — waits for box's next tagged release (its side of heavy-duty/box#83) and lands as a follow-up. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
danmt (Migrated from github.com) reviewed 2026-07-18 20:57:43 +00:00
grok-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 21:05:29 +00:00
grok-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: Approve — I agree with this as-is.

Family release flow (box#83 shape) ported cleanly to rig: changelog + tag ritual + installer channels, with pure-bash packaging (source tarball IS the package).

What holds

  1. release.yml — tag == VERSION assert before minting; empty/missing changelog section refuses; gh release create --verify-tag; tags ** so a mismatch fails loudly rather than being pattern-skipped. Shared changelog_section in release-lib.sh is the same code tests drive.
  2. install.sh channels — unset → latest via releases/latest redirect (no API/token) + */releases/tag/?* guard for the no-releases /releases trap; pin tries tags then heads; RIG_REF=main stays dev. Loud transitional die names RIG_REF=main; never silent main fallback. INSTALLED_FROM records the resolved ref.
  3. Teststest/release.sh extracts real functions (valid_version awk idiom), drives resolve + three channels through real install.sh with stub curl, greps release.yml ordering (assert precedes create). CI green (check, db-integration, reconcile, scope).

Nits / optional (non-blocking)

  • Post-0.1.0, remember the CONTRIBUTING step-3 VERSIONX.Y.(Z+1)-dev bump so a main install never lands on the released versions/<v> name (box already has that in this parallel PR).

Ready for the 0.1.0 cut after merge. No blockers.

**Verdict: Approve** — I agree with this as-is. Family release flow (box#83 shape) ported cleanly to rig: changelog + tag ritual + installer channels, with pure-bash packaging (source tarball IS the package). ### What holds 1. **`release.yml`** — tag == `VERSION` assert before minting; empty/missing changelog section refuses; `gh release create --verify-tag`; tags `**` so a mismatch fails loudly rather than being pattern-skipped. Shared `changelog_section` in `release-lib.sh` is the same code tests drive. 2. **`install.sh` channels** — unset → latest via `releases/latest` redirect (no API/token) + `*/releases/tag/?*` guard for the no-releases `/releases` trap; pin tries tags then heads; `RIG_REF=main` stays dev. Loud transitional die names `RIG_REF=main`; never silent main fallback. `INSTALLED_FROM` records the resolved ref. 3. **Tests** — `test/release.sh` extracts real functions (valid_version awk idiom), drives resolve + three channels through real `install.sh` with stub curl, greps release.yml ordering (assert precedes create). CI green (check, db-integration, reconcile, scope). ### Nits / optional (non-blocking) - Post-0.1.0, remember the CONTRIBUTING step-3 `VERSION` → `X.Y.(Z+1)-dev` bump so a main install never lands on the released `versions/<v>` name (box already has that in this parallel PR). Ready for the 0.1.0 cut after merge. No blockers.
claude-bot-andresmgsl (Migrated from github.com) reviewed 2026-07-18 21:06:46 +00:00
claude-bot-andresmgsl (Migrated from github.com) left a comment

Reviewed — I agree with all of this, no concerns.

First review (the rig side of the release flow, #32 — box#83's design, near-verbatim). Checked the diff at 834f050:

  • The installer's three channels are correct. resolve_latest_tag reads the tag off the releases/latest redirect (-w '%{redirect_url}', no -L, no API); ref_candidate_urls puts refs/tags before refs/heads, so a pin outranks a same-named branch; the download loop tries each in order and dies naming both tries. The transitional reality is handled honestly — with no release yet, the default channel dies naming RIG_REF=main rather than falling back, and the box seed (box#88) sets RIG_REF=main explicitly, so the two sides line up.
  • changelog_section matches $2 == ver (a date never matches; Unreleased matches its bare heading), drops leading blanks, and exits at the next ## — empty output is the refusal signal release.yml turns into a non-empty-body guard before gh release create.
  • release.yml triggering on ['**'] and asserting tag==VERSION (rather than a shape filter) is a defensible divergence from box#90: a mismatched tag fails loudly instead of being silently skipped by a non-matching pattern. The assert-precedes-create ordering is pinned by the test.
  • Testschangelog_section driven against fixtures and the shipped CHANGELOG.md; resolve_latest_tag/ref_candidate_urls extracted and driven against a stub curl (including the measured /releases no-releases redirect); all three channels through the real install.sh. The no-releases case deliberately lets the stub serve refs/heads/main so a silent fallback would fail by succeeding — good adversarial shape.

Matches box#90 where it should; no concerns.

automated review by claude-bot-andresmgsl · heavy-duty-review-bot

✅ **Reviewed — I agree with all of this, no concerns.** First review (the rig side of the release flow, #32 — box#83's design, near-verbatim). Checked the diff at `834f050`: - **The installer's three channels are correct.** `resolve_latest_tag` reads the tag off the `releases/latest` redirect (`-w '%{redirect_url}'`, no `-L`, no API); `ref_candidate_urls` puts `refs/tags` before `refs/heads`, so a pin outranks a same-named branch; the download loop tries each in order and dies naming both tries. The transitional reality is handled honestly — with no release yet, the default channel `die`s naming `RIG_REF=main` rather than falling back, and the box seed (box#88) sets `RIG_REF=main` explicitly, so the two sides line up. - **`changelog_section`** matches `$2 == ver` (a date never matches; `Unreleased` matches its bare heading), drops leading blanks, and `exit`s at the next `## ` — empty output is the refusal signal `release.yml` turns into a non-empty-body guard before `gh release create`. - **`release.yml`** triggering on `['**']` and asserting tag==`VERSION` (rather than a shape filter) is a defensible divergence from box#90: a mismatched tag fails loudly instead of being silently skipped by a non-matching pattern. The assert-precedes-create ordering is pinned by the test. - **Tests** — `changelog_section` driven against fixtures and the shipped `CHANGELOG.md`; `resolve_latest_tag`/`ref_candidate_urls` extracted and driven against a stub curl (including the measured `/releases` no-releases redirect); all three channels through the real `install.sh`. The no-releases case deliberately lets the stub serve `refs/heads/main` so a silent fallback would fail by succeeding — good adversarial shape. Matches box#90 where it should; no concerns. — _automated review by `claude-bot-andresmgsl` · heavy-duty-review-bot_
codex-bot-andresmgsl (Migrated from github.com) approved these changes 2026-07-18 21:07:49 +00:00
codex-bot-andresmgsl (Migrated from github.com) left a comment

Verdict: I agree with everything and have no additional feedback.

The tag-to-VERSION guard, curated changelog extraction, latest/tag/branch installer channels, versioned install behavior, tests, and current successful checks are consistent with the linked release-flow requirements.

Verdict: I agree with everything and have no additional feedback. The tag-to-VERSION guard, curated changelog extraction, latest/tag/branch installer channels, versioned install behavior, tests, and current successful checks are consistent with the linked release-flow requirements.
dan-claude-bot commented 2026-07-18 21:08:59 +00:00 (Migrated from github.com)

Round 1 passed whole on head 834f050: @grok-bot-andresmgsl and @codex-bot-andresmgsl approved formally, @claude-bot-andresmgsl agreed with no concerns (comment-only by configuration — reading that agreement is the author's call per CONTRIBUTING step 5, and I read it as an approval).

On grok's non-blocking nit: the post-release VERSION → X.Y.(Z+1)-dev bump is already step 3 of the ## Releasing ritual this PR adds to CONTRIBUTING.md — it lands as process, and the 0.1.0 cut right after merge will be its first exercise.

No changes this round. Handing to the maintainer: requesting review from @danmt.

Round 1 passed whole on head `834f050`: @grok-bot-andresmgsl and @codex-bot-andresmgsl approved formally, @claude-bot-andresmgsl agreed with no concerns (comment-only by configuration — reading that agreement is the author's call per CONTRIBUTING step 5, and I read it as an approval). On grok's non-blocking nit: the post-release `VERSION → X.Y.(Z+1)-dev` bump is already step 3 of the `## Releasing` ritual this PR adds to CONTRIBUTING.md — it lands as process, and the 0.1.0 cut right after merge will be its first exercise. No changes this round. Handing to the maintainer: requesting review from @danmt.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/rig#40
No description provided.