feat: release flow — CHANGELOG, release.yml, and a tag-resolving installer (#32) #40
9 changed files with 486 additions and 20 deletions
23
.github/scripts/release-lib.sh
vendored
Normal file
23
.github/scripts/release-lib.sh
vendored
Normal file
|
|
@ -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 <file> <version>
|
||||||
|
#
|
||||||
|
# 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 '## <version> — <date>' 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"
|
||||||
|
}
|
||||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -20,6 +20,8 @@ jobs:
|
||||||
shellcheck -x "${files[@]}"
|
shellcheck -x "${files[@]}"
|
||||||
- name: cli tests
|
- name: cli tests
|
||||||
run: bash test/cli.sh
|
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
|
# 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
|
# stands up throwaway containers, and a slow image pull must never delay the
|
||||||
|
|
|
||||||
48
.github/workflows/release.yml
vendored
Normal file
48
.github/workflows/release.yml
vendored
Normal file
|
|
@ -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/<tag>).
|
||||||
|
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"
|
||||||
31
CHANGELOG.md
Normal file
31
CHANGELOG.md
Normal file
|
|
@ -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/<v>` 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/<tag>.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.
|
||||||
|
|
@ -36,9 +36,29 @@ labels tell you where everything is without opening anything.
|
||||||
With three formal head-current approvals the labels workflow requests it
|
With three formal head-current approvals the labels workflow requests it
|
||||||
automatically; when part of the panel is comment-only, reading their
|
automatically; when part of the panel is comment-only, reading their
|
||||||
agreement is the author's judgment, so the author makes the request.
|
agreement is the author's judgment, so the author makes the request.
|
||||||
7. **Checks must be green**: `shellcheck` and `bash test/cli.sh` locally
|
7. **Checks must be green**: `shellcheck`, `bash test/cli.sh` and
|
||||||
mirror what CI runs; the db dump/restore round-trip
|
`bash test/release.sh` locally mirror what CI runs; the db dump/restore
|
||||||
(`test/db-integration.sh`) executes in CI where Docker is present.
|
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/<v>` layout.
|
||||||
|
|
||||||
## Labels — who sets what
|
## Labels — who sets what
|
||||||
|
|
||||||
|
|
|
||||||
39
README.md
39
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
|
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/<ref>`.
|
||||||
|
|
||||||
|
> **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`):
|
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
|
**Re-running is a safe converge.** Installing a version you already have
|
||||||
changes nothing and says so (`RIG_REINSTALL=1` replaces that version's
|
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
|
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
|
"re-run any time to upgrade" stays true, and now means *upgrade to the
|
||||||
installed as the way back:
|
latest release*; every version you had stays installed as the way back:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
rig versions # what is installed, which is current, which is running
|
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
|
> **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
|
> above.** The seed preinstalls rig via its curl installer, which resolves
|
||||||
> `RIG_REPO`/`RIG_REF` (default `heavy-duty/rig@main`, branches only — rig
|
> `RIG_REPO`/`RIG_REF` — and since rig#32 the installer defaults to the
|
||||||
> cuts no tags yet). That inverts the install edge on this page: rig installs
|
> **latest release**, with `RIG_REF=<tag>` the pin and `RIG_REF=main` the
|
||||||
> box on host-class machines, and box guests now install rig — both tracking
|
> dev channel. Until rig cuts 0.1.0 there is no release to resolve, so the
|
||||||
> a moving `main` until a release flow exists (rig#32). `RIG_REPO`/`RIG_REF`
|
> seed must set `RIG_REF=main` explicitly (the default channel fails loudly
|
||||||
> are the pin points the day there is something to pin to, or point them at a
|
> rather than falling back). That inverts the install edge on this page:
|
||||||
> frozen branch of your own fork. The seed side of this edge is box#81's to
|
> rig installs box on host-class machines, and box guests now install rig.
|
||||||
> document.
|
> `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
|
### The identity model
|
||||||
|
|
||||||
|
|
|
||||||
6
bin/rig
6
bin/rig
|
|
@ -95,8 +95,10 @@ commands:
|
||||||
|
|
||||||
install/upgrade:
|
install/upgrade:
|
||||||
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
curl -fsSL https://raw.githubusercontent.com/heavy-duty/rig/main/install.sh | bash
|
||||||
Re-run any time: an installed version converges (no-op), a new one
|
Installs the latest RELEASE (RIG_REF=<tag> pins one, RIG_REF=main
|
||||||
installs side by side at <root>/versions/<v> and becomes the default.
|
tracks the development tree). Re-run any time: an installed version
|
||||||
|
converges (no-op), a new one installs side by side at
|
||||||
|
<root>/versions/<v> and becomes the default.
|
||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
71
install.sh
71
install.sh
|
|
@ -3,6 +3,15 @@ set -euo pipefail
|
||||||
|
|
||||||
# rig installer — intended for: curl -fsSL .../install.sh | bash
|
# 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=<tag> that release, pinned (a tag outranks a branch of the
|
||||||
|
# same name)
|
||||||
|
# RIG_REF=<branch> the development tree, e.g. RIG_REF=main
|
||||||
|
#
|
||||||
# Downloads the rig repo tarball and installs it into the VERSIONED layout
|
# Downloads the rig repo tarball and installs it into the VERSIONED layout
|
||||||
# under $DEST (box#79's layout, ported — heavy-duty/rig#35):
|
# under $DEST (box#79's layout, ported — heavy-duty/rig#35):
|
||||||
#
|
#
|
||||||
|
|
@ -25,7 +34,7 @@ set -euo pipefail
|
||||||
# review.
|
# review.
|
||||||
|
|
||||||
REPO="${RIG_REPO:-heavy-duty/rig}"
|
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}"
|
DEST="${RIG_HOME:-$HOME/.local/share/rig}"
|
||||||
if [ "$(id -u)" -eq 0 ]; then
|
if [ "$(id -u)" -eq 0 ]; then
|
||||||
BINDIR="${RIG_BIN:-/usr/local/bin}"
|
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."
|
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 <owner/repo> — print the latest RELEASE tag, resolved by
|
||||||
|
# following the releases/latest redirect and reading the Location header
|
||||||
|
# (curl's %{redirect_url} is that header, parsed): no API, no token, no
|
||||||
|
# rate-limit pain. A repo with no releases redirects to /releases — not to
|
||||||
|
# /releases/tag/<tag> — so this returns 1 there instead of inventing a ref,
|
||||||
|
# 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 <owner/repo> <ref> — 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 -----------------------------------------------------------
|
# --- prerequisites -----------------------------------------------------------
|
||||||
# curl only when something must be downloaded — a local RIG_INSTALL_SOURCE
|
# 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.
|
# 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
|
if [ -n "${RIG_INSTALL_SOURCE:-}" ]; then
|
||||||
SRCDESC="local source $RIG_INSTALL_SOURCE"
|
SRCDESC="local source $RIG_INSTALL_SOURCE"
|
||||||
else
|
else
|
||||||
SRCDESC="$REPO@$REF"
|
SRCDESC="$REPO@${REF:-latest-release}" # refined once the tag resolves
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Flip $DEST/current to versions/<v> atomically: build the new link beside it,
|
# Flip $DEST/current to versions/<v> 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"
|
die "RIG_INSTALL_SOURCE is set but is neither a directory nor a tarball: $SRC"
|
||||||
fi
|
fi
|
||||||
else
|
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"
|
INSTALLED_FROM="$REPO@$REF"
|
||||||
URL="https://github.com/$REPO/archive/refs/heads/$REF.tar.gz"
|
|
||||||
log "installing rig ($REPO@$REF)"
|
log "installing rig ($REPO@$REF)"
|
||||||
log "downloading $URL"
|
got=""
|
||||||
curl -fsSL "$URL" -o "$TMPDIR/rig.tar.gz" \
|
for URL in "${urls[@]}"; do
|
||||||
|| die "failed to download $URL"
|
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"
|
log "extracting archive"
|
||||||
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
|
tar -xzf "$TMPDIR/rig.tar.gz" -C "$TMPDIR" \
|
||||||
|
|
|
||||||
260
test/release.sh
Normal file
260
test/release.sh
Normal file
|
|
@ -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 <desc> <want_exit> <want_substr> <cmd...>
|
||||||
|
# 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 <n> — 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 <home> <bin> [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/<tag>, 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=<tag>: 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=<tag> 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=<branch>: 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 ]
|
||||||
Loading…
Reference in a new issue