feat(release): release-artifact parity gate (FU5, age-8duhj)

Remove the vestigial in-repo homebrew-tap/Formula/agentops.rb (frozen at
version 2.31.0 + license MIT while the repo is 3.2.0 / Apache-2.0); the live
tap is generated by GoReleaser into boshu2/homebrew-agentops. Replace the tap
README with a pointer that documents this and forbids re-adding a formula.

Make the .goreleaser.yml archive files: list explicit (README.md, LICENSE,
CHANGELOG.md) so the packed README always matches the tagged commit rather than
relying on GoReleaser defaults.

Add scripts/check-release-parity.sh: asserts (a) tarball README sha256 == repo
README, (b) any in-repo formula license == LICENSE (Apache-2.0), (c) any in-repo
formula version == release tag. Wired into .github/workflows/release.yml as a
post-build gate over each dist/ tarball. Fixture-based bats coverage in
tests/scripts/check-release-parity.bats (red on stale README / wrong license /
stale version, green on parity).
This commit is contained in:
boshu
2026-07-13 18:03:58 -04:00
parent f534d9f70e
commit 6faa91d2fa
6 changed files with 422 additions and 81 deletions
+48
View File
@@ -222,6 +222,16 @@ jobs:
echo "No existing release for $VERSION"
fi
- name: Release parity pre-check (inputs, fail-closed)
run: |
VERSION="${{ steps.version.outputs.version }}"
chmod +x scripts/check-release-parity.sh
# Input-level check BEFORE anything is published: lints any in-repo
# formula against LICENSE and the tag (formula-only mode — no
# tarball yet). A divergence here means no release asset and no tap
# commit ever exist.
scripts/check-release-parity.sh --tag "$VERSION"
- name: Publish with GoReleaser
uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7
with:
@@ -231,6 +241,44 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }}
- name: Verify published artifacts (identity-bound parity gate)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
VERSION="${{ steps.version.outputs.version }}"
chmod +x scripts/check-release-parity.sh
# Identity-bound: download the EXACT assets the release now serves
# (not the local dist/ copies) and parity-check those. The checkout
# is at the tag, so the working-tree README is the tag README.
# GoReleaser wraps flat (wrap_in_directory: false), so README.md
# sits at the tarball root. On ANY divergence: delete the release
# so no bad asset stays served (the tap formula then points at a
# missing asset and brew fails closed on download, not silently).
dl="$(mktemp -d)"
fail=0
for name in ao-darwin-arm64.tar.gz ao-darwin-amd64.tar.gz \
ao-linux-arm64.tar.gz ao-linux-amd64.tar.gz; do
echo "== parity (published asset): $name =="
# A failed download is itself a divergence (missing/renamed
# asset) and must reach the rollback path, not abort under set -e.
if ! gh release download "$VERSION" --pattern "$name" --dir "$dl"; then
echo "FAIL download: $name missing from release $VERSION" >&2
fail=1
continue
fi
scripts/check-release-parity.sh --tarball "$dl/$name" --tag "$VERSION" || fail=1
done
if [ "$fail" -ne 0 ]; then
echo "parity divergence in PUBLISHED assets — rolling back release $VERSION" >&2
if gh release delete "$VERSION" --yes; then
echo "rollback complete: release $VERSION deleted" >&2
else
echo "ROLLBACK FAILED: release $VERSION is still served and DIVERGENT — delete it manually NOW: gh release delete $VERSION --yes" >&2
fi
exit 1
fi
- name: Set release notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7
View File
@@ -23,6 +23,13 @@ archives:
- id: ao
name_template: ao-{{ .Os }}-{{ .Arch }}
wrap_in_directory: false
# Pack the repo's own docs at tag time (explicit, not GoReleaser defaults) so
# the tarball README/LICENSE always match the tagged commit. Enforced by
# scripts/check-release-parity.sh (tarball README sha256 == repo README).
files:
- README.md
- LICENSE
- CHANGELOG.md
checksum:
name_template: checksums.txt
-52
View File
@@ -1,52 +0,0 @@
# typed: false
# frozen_string_literal: true
class Agentops < Formula
desc "AI-assisted development workflow CLI"
homepage "https://github.com/boshu2/agentops"
version "2.31.0"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/boshu2/agentops/releases/download/v#{version}/ao-darwin-arm64.tar.gz"
sha256 "aad4483f0b3209a02d10abf412eb29e78475312061d05d6a71c0fb4224bb5056"
else
url "https://github.com/boshu2/agentops/releases/download/v#{version}/ao-darwin-amd64.tar.gz"
sha256 "3ad4c80b1a5784c8bbbdb892feef8e10e695fae31f6f39ee0fa74c264086a091"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/boshu2/agentops/releases/download/v#{version}/ao-linux-arm64.tar.gz"
sha256 "37e674a6311004a1031cbb90886bc9aef75b70ced8e6d21b65ab8001788a1687"
else
url "https://github.com/boshu2/agentops/releases/download/v#{version}/ao-linux-amd64.tar.gz"
sha256 "df8ff9a41d748552b7dfb67dc8e4e19628fdf0e51a04f315493305bab1554b7f"
end
end
def install
bin.install "ao"
end
def caveats
<<~EOS
AgentOps ao CLI installed!
Commands:
ao forge transcript <path> # Extract from JSONL transcripts
ao forge markdown <path> # Extract from markdown files
ao ratchet record <type> # Record progress
ao ratchet verify <epic> # Verify completion
For the Claude Code plugin, run:
claude /plugin add boshu2/agentops
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/ao --version")
end
end
+16 -29
View File
@@ -1,8 +1,20 @@
# Homebrew Tap for AgentOps
# Homebrew Tap for AgentOps — pointer only
Install the ao CLI via Homebrew.
**This directory does not contain the live Homebrew formula.**
## Quick Install
The real tap lives at **[github.com/boshu2/homebrew-agentops](https://github.com/boshu2/homebrew-agentops)**
and its `Formula/agentops.rb` is generated automatically by **GoReleaser** on every
release tag (see the `brews:` block in [`.goreleaser.yml`](../.goreleaser.yml) and the
[Release Publisher workflow](../.github/workflows/release.yml)). The generated formula
carries the correct version, `license "Apache-2.0"`, and per-artifact `sha256` sums.
Do **not** re-add a hand-written `Formula/agentops.rb` here. A checked-in formula
drifts silently from the release — it once shipped `version "2.31.0"` +
`license "MIT"` while the repo was 3.2.0 / Apache-2.0, so anyone browsing the repo
read the wrong license. `scripts/check-release-parity.sh` guards against a
reintroduced in-repo formula that disagrees with `LICENSE` or the release tag.
## Install
```bash
brew tap boshu2/agentops https://github.com/boshu2/homebrew-agentops
@@ -15,34 +27,9 @@ Or directly:
brew install boshu2/agentops/agentops
```
## Update to Latest
## Update
```bash
brew update && brew upgrade agentops
ao version
```
## Commands
```bash
ao forge transcript <path> # Extract from JSONL transcripts
ao forge markdown <path> # Extract from markdown files
ao ratchet record <type> # Record progress
ao ratchet verify <epic> # Verify completion
```
## Claude Code Plugin
The ao CLI integrates with the AgentOps Claude Code plugin:
```bash
claude plugin add boshu2/agentops
```
## Development
To install from HEAD:
```bash
brew install --HEAD agentops
```
+234
View File
@@ -0,0 +1,234 @@
#!/usr/bin/env bash
#
# check-release-parity.sh — assert release-artifact parity so a built tarball,
# the in-repo tree, and any tap formula cannot silently diverge (FU5, age-8duhj).
#
# Three checks:
# (a) README parity — README.md inside the built tarball has the same sha256
# as the repo README at the release ref. Catches the
# v3.2.0 hazard where the tarball shipped a stale README.
# (b) License parity — every in-repo Homebrew formula (homebrew-tap/**/*.rb)
# declares `license "<L>"` matching the repo LICENSE
# (Apache-2.0). Catches the vestigial dir that said MIT.
# (c) Version parity — every in-repo formula's `version "<V>"` matches the
# release tag. Catches a hand-edited formula frozen at an
# old version (it once said 2.31.0 while the repo was 3.2.0).
#
# The canonical tap is generated by GoReleaser into boshu2/homebrew-agentops, so
# in the normal case there is NO in-repo formula and (b)/(c) simply pass — the
# gate then acts as a regression guard against anyone re-adding a stale formula.
#
# Runs in CI (post-build, in .github/workflows/release.yml) and locally.
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: scripts/check-release-parity.sh [options]
Assert parity between a built release tarball, the repo tree, and any in-repo
Homebrew formula.
Options:
--tarball PATH Built ao-<os>-<arch>.tar.gz to check README parity against.
Omit to skip check (a) and run only formula checks.
--ref GITREF Compare the tarball README against README.md at this git
ref (e.g. a tag). Default: the working-tree README.md
(correct in CI, where the checkout is already at the tag).
--tag TAG Release tag (e.g. v3.2.0). Any in-repo formula version
must equal it (leading 'v' ignored). Omit to skip check (c).
--repo-root DIR Repo root to scan. Default: git toplevel, else the
script's parent directory.
--expected-license L Override the expected SPDX license. Default: derived from
the repo LICENSE file (Apache-2.0).
-h, --help Show this help.
Exit status: 0 = all parity checks pass; 1 = a divergence was found; 2 = usage error.
USAGE
}
fail() {
echo "check-release-parity: ERROR: $*" >&2
}
TARBALL=""
REF=""
TAG=""
REPO_ROOT=""
EXPECTED_LICENSE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--tarball)
TARBALL="${2:-}"
shift 2
;;
--ref)
REF="${2:-}"
shift 2
;;
--tag)
TAG="${2:-}"
shift 2
;;
--repo-root)
REPO_ROOT="${2:-}"
shift 2
;;
--expected-license)
EXPECTED_LICENSE="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
fail "unknown argument: $1"
usage >&2
exit 2
;;
esac
done
# Resolve the repo root.
if [[ -z "$REPO_ROOT" ]]; then
if REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null)"; then
:
else
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
fi
fi
if [[ ! -d "$REPO_ROOT" ]]; then
fail "repo root does not exist: $REPO_ROOT"
exit 2
fi
# sha256 of stdin, printed as the bare hex digest (portable across sha256sum /
# shasum -a 256).
sha256_stdin() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | awk '{print $1}'
else
shasum -a 256 | awk '{print $1}'
fi
}
# Derive the expected SPDX license from the repo LICENSE file when not overridden.
if [[ -z "$EXPECTED_LICENSE" ]]; then
license_file="$REPO_ROOT/LICENSE"
if [[ -f "$license_file" ]]; then
if grep -qi "Apache License" "$license_file" && grep -qi "Version 2.0" "$license_file"; then
EXPECTED_LICENSE="Apache-2.0"
elif grep -qi "MIT License" "$license_file"; then
EXPECTED_LICENSE="MIT"
else
fail "could not derive license from $license_file; pass --expected-license"
exit 2
fi
else
fail "no LICENSE file at $license_file; pass --expected-license"
exit 2
fi
fi
FAILURES=0
CHECKS=0
# ── Check (a): tarball README sha256 == repo README sha256 ────────────────────
if [[ -n "$TARBALL" ]]; then
CHECKS=$((CHECKS + 1))
if [[ ! -f "$TARBALL" ]]; then
fail "tarball not found: $TARBALL"
FAILURES=$((FAILURES + 1))
else
# README.md sits at the archive root (wrap_in_directory: false).
tarball_readme_sha=""
if ! tarball_readme_sha="$(tar -xzf "$TARBALL" -O README.md 2>/dev/null | sha256_stdin)"; then
fail "tarball $TARBALL does not contain README.md at its root"
FAILURES=$((FAILURES + 1))
tarball_readme_sha=""
fi
repo_readme_sha=""
if [[ -n "$REF" ]]; then
if ! repo_readme_sha="$(git -C "$REPO_ROOT" show "${REF}:README.md" 2>/dev/null | sha256_stdin)"; then
fail "cannot read README.md at ref '$REF' in $REPO_ROOT"
FAILURES=$((FAILURES + 1))
repo_readme_sha=""
fi
else
if [[ -f "$REPO_ROOT/README.md" ]]; then
repo_readme_sha="$(sha256_stdin < "$REPO_ROOT/README.md")"
else
fail "no README.md at $REPO_ROOT/README.md"
FAILURES=$((FAILURES + 1))
fi
fi
if [[ -n "$tarball_readme_sha" && -n "$repo_readme_sha" ]]; then
if [[ "$tarball_readme_sha" == "$repo_readme_sha" ]]; then
echo "PASS README parity: tarball README matches repo README (${tarball_readme_sha:0:12})"
else
fail "README divergence: tarball=${tarball_readme_sha:0:12} repo=${repo_readme_sha:0:12} (stale README packed in $TARBALL)"
FAILURES=$((FAILURES + 1))
fi
fi
fi
else
echo "SKIP README parity: no --tarball provided"
fi
# ── Checks (b)/(c): in-repo formula license + version parity ──────────────────
# Collect any Homebrew formula checked into the repo. The canonical formula is
# generated externally by GoReleaser, so finding none here is the healthy state.
formulae=()
if [[ -d "$REPO_ROOT/homebrew-tap" ]]; then
while IFS= read -r f; do
formulae+=("$f")
done < <(find "$REPO_ROOT/homebrew-tap" -type f -name '*.rb' 2>/dev/null | sort)
fi
if [[ ${#formulae[@]} -eq 0 ]]; then
echo "PASS formula parity: no in-repo Homebrew formula (generated externally by GoReleaser)"
else
tag_version="${TAG#v}"
for formula in "${formulae[@]}"; do
rel="${formula#"$REPO_ROOT"/}"
# (b) License.
CHECKS=$((CHECKS + 1))
formula_license="$(grep -oE 'license[[:space:]]+"[^"]+"' "$formula" | head -1 | sed -E 's/license[[:space:]]+"([^"]+)"/\1/')"
if [[ -z "$formula_license" ]]; then
fail "$rel declares no license string (expected \"$EXPECTED_LICENSE\")"
FAILURES=$((FAILURES + 1))
elif [[ "$formula_license" != "$EXPECTED_LICENSE" ]]; then
fail "$rel license \"$formula_license\" != repo license \"$EXPECTED_LICENSE\""
FAILURES=$((FAILURES + 1))
else
echo "PASS license parity: $rel license \"$formula_license\""
fi
# (c) Version — only when a release tag is supplied.
if [[ -n "$tag_version" ]]; then
CHECKS=$((CHECKS + 1))
formula_version="$(grep -oE 'version[[:space:]]+"[^"]+"' "$formula" | head -1 | sed -E 's/version[[:space:]]+"([^"]+)"/\1/')"
if [[ -z "$formula_version" ]]; then
fail "$rel declares no version string (expected \"$tag_version\")"
FAILURES=$((FAILURES + 1))
elif [[ "$formula_version" != "$tag_version" ]]; then
fail "$rel version \"$formula_version\" != release tag \"$tag_version\""
FAILURES=$((FAILURES + 1))
else
echo "PASS version parity: $rel version \"$formula_version\""
fi
fi
done
fi
echo "---"
if [[ "$FAILURES" -gt 0 ]]; then
echo "check-release-parity: FAIL ($FAILURES divergence(s) across $CHECKS check(s))" >&2
exit 1
fi
echo "check-release-parity: OK ($CHECKS check(s) passed)"
exit 0
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bats
# Acceptance surface for scripts/check-release-parity.sh (FU5, age-8duhj).
#
# Given a release-tag build, When artifacts are produced, Then the gate fails on
# README / license / version divergence between the tarball, an in-repo tap
# formula, and the repo tree — and passes when they agree.
#
# Fixtures are built in tmp trees (a synthetic --repo-root with its own LICENSE
# and README) so the checker never scans the real repo's homebrew-tap/.
setup() {
REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/../.." && pwd)"
SCRIPT="$REPO_ROOT/scripts/check-release-parity.sh"
FIX="$(mktemp -d "$BATS_TMPDIR/parity.XXXXXX")"
# Synthetic repo root with an Apache-2.0 LICENSE and a canonical README.
printf ' Apache License\n Version 2.0, January 2004\n' > "$FIX/LICENSE"
printf '# AgentOps\n\nOperating loop for coding agents — intent -> validated code.\n' > "$FIX/README.md"
# A "good" tarball packs the current repo README at its root (flat archive).
mkdir -p "$FIX/pack"
cp "$FIX/README.md" "$FIX/pack/README.md"
tar -czf "$FIX/ao-good.tar.gz" -C "$FIX/pack" README.md
# A "stale" tarball packs the PREVIOUS README (the v3.2.0 hazard).
printf '# AgentOps\n\nAutonomous code validation for coding agents.\n' > "$FIX/pack/README.md"
tar -czf "$FIX/ao-stale.tar.gz" -C "$FIX/pack" README.md
}
teardown() {
[ -n "${FIX:-}" ] && [ -d "$FIX" ] && find "$FIX" -mindepth 0 -delete 2>/dev/null || true
}
@test "checker exists and is executable" {
[ -f "$SCRIPT" ]
[ -x "$SCRIPT" ]
}
@test "--help prints usage and exits 0" {
run bash "$SCRIPT" --help
[ "$status" -eq 0 ]
[[ "$output" == *"Assert parity"* ]]
[[ "$output" == *"--tarball"* ]]
}
@test "red: stale README tarball -> gate FAILS naming README divergence" {
run bash "$SCRIPT" --tarball "$FIX/ao-stale.tar.gz" --repo-root "$FIX"
[ "$status" -eq 1 ]
[[ "$output" == *"README divergence"* ]]
}
@test "green: matching README tarball -> gate PASSES" {
run bash "$SCRIPT" --tarball "$FIX/ao-good.tar.gz" --repo-root "$FIX"
[ "$status" -eq 0 ]
[[ "$output" == *"README parity"* ]]
[[ "$output" == *"OK"* ]]
}
@test "green: no in-repo formula -> formula parity passes (GoReleaser owns the tap)" {
run bash "$SCRIPT" --tarball "$FIX/ao-good.tar.gz" --repo-root "$FIX" --tag v3.2.0
[ "$status" -eq 0 ]
[[ "$output" == *"no in-repo Homebrew formula"* ]]
}
@test "red: in-repo formula with wrong license (MIT vs Apache-2.0) -> gate FAILS" {
mkdir -p "$FIX/homebrew-tap/Formula"
cat > "$FIX/homebrew-tap/Formula/agentops.rb" <<'RB'
class Agentops < Formula
version "3.2.0"
license "MIT"
end
RB
run bash "$SCRIPT" --tarball "$FIX/ao-good.tar.gz" --repo-root "$FIX" --tag v3.2.0
[ "$status" -eq 1 ]
[[ "$output" == *'license "MIT" != repo license "Apache-2.0"'* ]]
}
@test "red: in-repo formula with stale version -> gate FAILS on version" {
mkdir -p "$FIX/homebrew-tap/Formula"
cat > "$FIX/homebrew-tap/Formula/agentops.rb" <<'RB'
class Agentops < Formula
version "2.31.0"
license "Apache-2.0"
end
RB
run bash "$SCRIPT" --tarball "$FIX/ao-good.tar.gz" --repo-root "$FIX" --tag v3.2.0
[ "$status" -eq 1 ]
[[ "$output" == *'version "2.31.0" != release tag "3.2.0"'* ]]
}
@test "green: in-repo formula matching license + version -> gate PASSES" {
mkdir -p "$FIX/homebrew-tap/Formula"
cat > "$FIX/homebrew-tap/Formula/agentops.rb" <<'RB'
class Agentops < Formula
version "3.2.0"
license "Apache-2.0"
end
RB
run bash "$SCRIPT" --tarball "$FIX/ao-good.tar.gz" --repo-root "$FIX" --tag v3.2.0
[ "$status" -eq 0 ]
[[ "$output" == *"license parity"* ]]
[[ "$output" == *"version parity"* ]]
}
@test "formula-only run (no --tarball) skips README check and still lints formula" {
mkdir -p "$FIX/homebrew-tap/Formula"
cat > "$FIX/homebrew-tap/Formula/agentops.rb" <<'RB'
class Agentops < Formula
version "3.2.0"
license "MIT"
end
RB
run bash "$SCRIPT" --repo-root "$FIX" --tag v3.2.0
[ "$status" -eq 1 ]
[[ "$output" == *"SKIP README parity"* ]]
[[ "$output" == *'license "MIT"'* ]]
}