From 415a83841417f06a660cd4a9db21ad37e3f77f3c Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:44:22 +0530 Subject: [PATCH 01/16] chore(release): set VERSION to 0.3.0 The Go module proxy already serves v0.1.0, v0.1.1 and v0.2.0 for github.com/GrayCodeAI/rho (hawk-era tags), so a VERSION of 0.0.1 made source builds report a version below the published ones and could never become @latest. The next rho release is v0.3.0. - VERSION: 0.0.1 -> 0.3.0 - compatibility matrix "stable": rho 0.3.0 with flux 0.0.1 (the pinned go.mod dependency) instead of the never-released rho 0.0.1 - testaudit: VERSION must be SemVer without a leading "v" and >= 0.3.0 Refs: F018, F024 Co-Authored-By: Claude Opus 5.5 --- VERSION | 2 +- internal/testaudit/release_version_test.go | 38 ++++++++++++++++++++++ testdata/compatibility-matrix.json | 4 +-- 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 internal/testaudit/release_version_test.go diff --git a/VERSION b/VERSION index 8acdd82b7..0d91a54c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.1 +0.3.0 diff --git a/internal/testaudit/release_version_test.go b/internal/testaudit/release_version_test.go new file mode 100644 index 000000000..59eb0f5b8 --- /dev/null +++ b/internal/testaudit/release_version_test.go @@ -0,0 +1,38 @@ +package testaudit + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/mod/semver" +) + +// readRepoFile reads a file relative to the repository root. +func readRepoFile(t *testing.T, rel string) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join(repoRoot(t), rel)) + if err != nil { + t.Fatalf("read %s: %v", rel, err) + } + return data +} + +// repoVersion returns the trimmed contents of the VERSION file. +func repoVersion(t *testing.T) string { + t.Helper() + return strings.TrimSpace(string(readRepoFile(t, "VERSION"))) +} + +func TestVersionFileIsSemVer(t *testing.T) { + v := repoVersion(t) + if strings.HasPrefix(v, "v") || !semver.IsValid("v"+v) { + t.Fatalf("VERSION = %q, want a SemVer version without a leading v (e.g. 0.3.0)", v) + } + // v0.1.0–v0.2.0 are hawk-era tags already on the Go module proxy; a lower + // VERSION would never become @latest. + if semver.Compare("v"+v, "v0.3.0") < 0 { + t.Fatalf("VERSION = %q is below 0.3.0, the first rho-named release", v) + } +} diff --git a/testdata/compatibility-matrix.json b/testdata/compatibility-matrix.json index 10c778c06..b3fb7e3ef 100644 --- a/testdata/compatibility-matrix.json +++ b/testdata/compatibility-matrix.json @@ -2,7 +2,7 @@ "$schema": "./compatibility-matrix.schema.json", "description": "Cross-repo compatibility tracking for GrayCodeAI repositories. Each `matrix` entry records a combination of component versions that have been tested together. CI runs the matrix on a schedule and on PR to ensure released components stay compatible.", "version": "1", - "updated": "2026-09-14", + "updated": "2026-09-27", "components": [ "rho", "flux" @@ -18,7 +18,7 @@ "name": "stable", "description": "Latest tested-together stable combination across the eco. This is what new users get when installing.", "components": { - "rho": "0.0.1", + "rho": "0.3.0", "flux": "0.0.1" } }, From b27c66c72512aa45358df34515f3f2d51da58090 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:44:41 +0530 Subject: [PATCH 02/16] build(release): build and CI-check windows/arm64 README promises binaries for linux/darwin/windows on amd64/arm64, `make build-all`, install.sh and the npm scaffolding (rho-win32-arm64) all handle windows/arm64, but .goreleaser.yml ignored that target and the CI build matrix excluded it, so no windows_arm64 archive was ever produced. `GOOS=windows GOARCH=arm64 CGO_ENABLED=0 go build ./cmd/rho` succeeds, so build the target instead of narrowing the promise: - .goreleaser.yml: drop the windows/arm64 ignore (six archives) - ci.yml: drop the matrix exclude so the target is compile-checked - npm assemble script: drop the comments about the missing target Refs: F282 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 3 --- .goreleaser.yml | 5 +---- npm/rho/scripts/assemble-platform-packages.js | 9 ++------- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8817694e0..40cce2441 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -487,9 +487,6 @@ jobs: matrix: goos: [linux, darwin, windows] goarch: [amd64, arm64] - exclude: - - goos: windows - goarch: arm64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/.goreleaser.yml b/.goreleaser.yml index 0a1251674..84cfa35de 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -22,7 +22,7 @@ before: - go mod verify # --------------------------------------------------------------------------- -# Builds — three OS × two arch (no Windows/arm64). +# Builds — linux/darwin/windows × amd64/arm64 (six targets). # Reproducible: `mod_timestamp` ties the binary timestamp to the commit time # rather than the build host's clock. # --------------------------------------------------------------------------- @@ -39,9 +39,6 @@ builds: goarch: - amd64 - arm64 - ignore: - - goos: windows - goarch: arm64 ldflags: - -s -w - -X main.Version={{.Version}} diff --git a/npm/rho/scripts/assemble-platform-packages.js b/npm/rho/scripts/assemble-platform-packages.js index 3b1d60924..2358de77a 100644 --- a/npm/rho/scripts/assemble-platform-packages.js +++ b/npm/rho/scripts/assemble-platform-packages.js @@ -106,19 +106,14 @@ async function main() { function gorel(platform, arch) { const ga = goosGoarch[platform]?.[arch]; if (!ga) throw new Error(`no goarch mapping for ${platform}-${arch}`); - // No windows/arm64 build per .goreleaser.yml `ignore`, but we still ship - // a sub-package target (see comment below) for completeness; the env var - // override is the real source there. const os = platform === 'win32' ? 'windows' : platform; const bin = platform === 'win32' ? 'rho.exe' : 'rho'; const dirName = `rho_${os}_${ga}${ga === 'arm64' ? '' : '_v1'}`; return path.join(distRoot, dirName, bin); } - // Note: rho's `.goreleaser.yml` currently ignores windows/arm64 in builds, - // so rho-win32-arm64 has no default GoReleaser artifact yet. The target is - // retained so that when the build matrix adds it (or CI supplies the - // binary via RHO_WIN32_ARM64), packaging works without script changes. + // All six targets are built by `.goreleaser.yml` (linux/darwin/windows × + // amd64/arm64). const targets = [ { platform: 'darwin', arch: 'arm64', binName: 'rho', From 6615361569c9f77dbef7fc2f436264df96a3d1a6 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:44:59 +0530 Subject: [PATCH 03/16] build(release): drop the Homebrew publisher The brews stanza pushed a formula to GrayCodeAI/homebrew-tap, a repository that does not exist, with HOMEBREW_TAP_TOKEN, a secret that is not set. On the next tag GoReleaser would have created the GitHub release and then failed in the brew publisher, leaving the run red and the release unsigned. `brews` is also deprecated in the pinned GoReleaser v2.17.0, so `goreleaser check` exited 1 on this config. Per the owner decision the tap is not being created now: remove the stanza and the unused HOMEBREW_TAP_TOKEN/TAP_GITHUB_TOKEN env wiring. `goreleaser check` now validates the config. Re-adding Homebrew (homebrew_casks) is documented as a follow-up in docs/RELEASING.md. Refs: F021 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 3 --- .goreleaser.yml | 20 +++----------------- 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ca97e975..c0edb1a9e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -45,9 +45,6 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Optional secrets used by some repos' goreleaser configs: - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} # Sign release artifacts with cosign keyless signing. The # id-token: write permission above enables OIDC federation with diff --git a/.goreleaser.yml b/.goreleaser.yml index 84cfa35de..d646ce0b9 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -117,23 +117,9 @@ changelog: order: 999 # --------------------------------------------------------------------------- -# Homebrew — publishes a formula to GrayCodeAI/homebrew-tap so users can -# `brew install graycodeai/tap/rho`. Requires HOMEBREW_TAP_TOKEN or -# TAP_GITHUB_TOKEN in the release workflow (both already wired there). -# --------------------------------------------------------------------------- -brews: - - repository: - owner: GrayCodeAI - name: homebrew-tap - token: "{{ .Env.HOMEBREW_TAP_TOKEN }}" - directory: Formula - homepage: "https://github.com/GrayCodeAI/rho" - description: "AI coding agent — reads, writes, and runs code in your terminal" - license: "MIT" - install: | - bin.install "rho" - test: | - system "#{bin}/rho", "--version" +# Homebrew and npm are intentionally not configured: neither the +# GrayCodeAI/homebrew-tap repository nor the @graycodeai npm packages exist. +# --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Release — auto-detect prereleases (rc/beta tags). Created on the repo From c6a721166f49457e9cf3d70889beeb9d8f7265c9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:46:29 +0530 Subject: [PATCH 04/16] ci(release): make a v* tag produce a signed, SBOM'd rho release The tag-triggered workflow had never completed (10 of 10 runs failed): - GoReleaser's `sboms:` stanza shells out to syft, which the runner does not have ("exec: syft: executable file not found"). Install it with anchore/sbom-action/download-syft pinned by SHA. - The cosign step ran `gh release download ... -D dist/` into the dist/ directory GoReleaser had just written, so it failed on the existing dist/checksums.txt before signing anything. Signing now happens inside GoReleaser (`signs:`, cosign keyless over checksums.txt, emitting the Sigstore bundle checksums.txt.sigstore.json) so the signature is uploaded with the release. cosign-installer is bumped to v4.1.2 (cosign v3), whose sign-blob requires --bundle. A final step re-downloads the published files and verifies them with the exact certificate identity install.sh uses. - `packages: write` was unused (no images or packages are published). - The checkout-flux step cloned sibling repos the build never used (go.mod pins published flux v0.0.1); the job now sets GOWORK=off and builds only from go.mod/go.sum, with the local-replace guard. - New scripts/check-release-tag.sh refuses a tag that is not "v" + VERSION or whose CHANGELOG.md has no non-empty section for it, and extracts that section as the release notes (--release-notes), so published notes match the curated changelog instead of an auto-generated list of every commit since the root. - Builds use -trimpath; /dist/ is gitignored. The shared templates are regenerated from the fixed files. `goreleaser check` passes and `goreleaser release --snapshot --clean --skip=publish,sign` builds six rho_* archives, six SPDX SBOMs and checksums.txt locally (signing needs the Actions OIDC token and was not exercised). testaudit now pins these invariants. Refs: F022, F023, F024, F031, F259 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 76 +++--- .gitignore | 2 + .goreleaser.yml | 100 ++++--- .shared-templates/.goreleaser.yml.tmpl | 95 +++++-- .../workflows/go-release.yml.tmpl | 63 ++++- internal/testaudit/release_workflow_test.go | 249 ++++++++++++++++++ scripts/check-release-tag.sh | 72 +++++ 7 files changed, 557 insertions(+), 100 deletions(-) create mode 100644 internal/testaudit/release_workflow_test.go create mode 100755 scripts/check-release-tag.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0edb1a9e..fa0b179ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,5 +1,6 @@ -# Canonical release workflow for GrayCodeAI Go binary repos. -# Triggered by release-please when it pushes a v* tag. +# Release workflow for rho. A maintainer pushes a v tag after the +# release PR (VERSION + CHANGELOG) has merged; creating v* tags is restricted by +# the repository ruleset "protect-release-tags". See docs/RELEASING.md. # Source of truth: .shared-templates/workflows/go-release.yml.tmpl name: release @@ -9,25 +10,32 @@ on: tags: ["v*"] permissions: - contents: write - packages: write - id-token: write # for cosign keyless signing if enabled later + contents: write # create the GitHub release and upload its assets + id-token: write # cosign keyless signing (GitHub OIDC -> Sigstore Fulcio) + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: goreleaser: runs-on: ubuntu-latest env: GOPROXY: "https://proxy.golang.org,direct" + # Release builds resolve every dependency (including flux) from + # go.mod/go.sum through the public proxy — never from sibling checkouts. + GOWORK: "off" steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # goreleaser needs full history for changelog + persist-credentials: false - # Releases build against the sibling repos resolved via the workspace go.work. - - uses: ./.github/actions/checkout-flux - with: - ref: ${{ github.ref_name }} + # Refuses a tag that disagrees with VERSION or has no CHANGELOG section, + # and extracts that section as the release notes. + - name: Check tag against VERSION and CHANGELOG + run: bash ./scripts/check-release-tag.sh --notes "${RUNNER_TEMP}/release-notes.md" "${GITHUB_REF_NAME}" - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 @@ -35,6 +43,19 @@ jobs: go-version: "1.26.6" cache: true + - name: Refuse local replace directives + run: bash ./scripts/check-no-replace-directives.sh + + # GoReleaser shells out to syft for the `sboms:` stanza; the runner image + # does not ship it. + - name: Install syft + uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + + # GoReleaser shells out to cosign for the `signs:` stanza (keyless + # signature over checksums.txt, uploaded as checksums.txt.sigstore.json). + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Run GoReleaser uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: @@ -42,30 +63,23 @@ jobs: # Must match a real goreleaser release — verify at # https://github.com/goreleaser/goreleaser/releases before bumping. version: "v2.17.0" - args: release --clean + args: release --clean --release-notes=${{ runner.temp }}/release-notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Sign release artifacts with cosign keyless signing. The - # id-token: write permission above enables OIDC federation with - # Fulcio's root CA — no long-lived signing keys to manage. - - name: Install cosign - uses: sigstore/cosign-installer@d8a3f51ef971a853e51de6231a89f4483c5a7a0e # v3.4.0 - - - name: Sign release checksums with cosign + - name: Verify the published signature + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail - # Download the checksums artifact produced by goreleaser. - gh release download "${GITHUB_REF_NAME}" --pattern "checksums.txt" -D dist/ - # Sign with keyless OIDC — output is dist/checksums.txt.sig (bundle). - cosign sign-blob \ - --yes \ - --output-signature dist/checksums.txt.sig \ - --output-certificate dist/checksums.txt.cert \ - dist/checksums.txt - # Upload the signature and certificate back to the release. - gh release upload "${GITHUB_REF_NAME}" \ - dist/checksums.txt.sig \ - dist/checksums.txt.cert - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Re-download what users will download and verify it the way + # install.sh does, so a broken signature fails this run loudly. + verify_dir="$(mktemp -d)" + gh release download "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \ + --pattern checksums.txt --pattern checksums.txt.sigstore.json \ + --dir "${verify_dir}" + cosign verify-blob \ + --bundle "${verify_dir}/checksums.txt.sigstore.json" \ + --certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@${GITHUB_REF}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "${verify_dir}/checksums.txt" diff --git a/.gitignore b/.gitignore index 44c3c9b3f..457aa9f92 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ # Ignore build output in bin/, but track committed source scripts like bin/buf. bin/* !bin/buf +# GoReleaser output (goreleaser release --snapshot / make release). +/dist/ rho_bin *.exe *.dll diff --git a/.goreleaser.yml b/.goreleaser.yml index d646ce0b9..783e89c25 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,21 +1,17 @@ -# Canonical GrayCodeAI goreleaser config for Go binary repos. -# Source of truth: .shared-templates/.goreleaser.yml.tmpl +# GoReleaser config for rho (the terminal AI coding agent). +# Derived from .shared-templates/.goreleaser.yml.tmpl; rho-specific deltas are +# commented inline. Run by .github/workflows/release.yml on a v* tag; see +# docs/RELEASING.md for the release process. # -# Placeholders rendered per repo: -# rho — short repo name (e.g. rho, harrier, swift) -# ./ — main package path (e.g. ./ or ./cmd/harrier) -# AI coding agent — reads, writes, and runs code in your terminal — short single-line description for brew/nfpms -# main — Go package path holding Version vars -# (e.g. main or github.com/GrayCodeAI/rho/internal/version) -# -# Repos with PRO/special features (e.g. swift's macOS notarization, shrike's -# nfpms) extend this template with extra sections instead of replacing it. +# Local validation (no publishing, no signing): +# goreleaser check +# goreleaser release --snapshot --clean --skip=publish,sign version: 2 project_name: rho # --------------------------------------------------------------------------- -# Pre-build hooks — keep go.mod tidy and verified. +# Pre-build hooks — verify module checksums before building anything. # --------------------------------------------------------------------------- before: hooks: @@ -32,6 +28,10 @@ builds: binary: rho env: - CGO_ENABLED=0 + # Build strictly from go.mod/go.sum, never from a developer go.work. + - GOWORK=off + flags: + - -trimpath goos: - linux - darwin @@ -47,7 +47,9 @@ builds: mod_timestamp: "{{ .CommitTimestamp }}" # --------------------------------------------------------------------------- -# Archives — tar.gz on Unix, zip on Windows. Includes README + LICENSE. +# Archives — tar.gz on Unix, zip on Windows. Names are +# rho___.; install.sh and .github/actions/rho depend +# on this exact shape. # --------------------------------------------------------------------------- archives: - id: default @@ -70,12 +72,43 @@ source: name_template: "{{ .ProjectName }}_{{ .Version }}_source" # --------------------------------------------------------------------------- -# Checksums — SHA-256, single file per release. +# SBOMs — one SPDX document per archive, generated with syft. The release +# workflow installs syft before GoReleaser runs. +# --------------------------------------------------------------------------- +sboms: + - artifacts: archive + documents: + - "${artifact}.spdx.sbom.json" + +# --------------------------------------------------------------------------- +# Checksums — SHA-256 over every uploaded artifact, single file per release. # --------------------------------------------------------------------------- checksum: name_template: checksums.txt algorithm: sha256 +# --------------------------------------------------------------------------- +# Signing — cosign keyless (Sigstore) signature over checksums.txt, using the +# release workflow's GitHub OIDC identity. The output is a self-contained +# Sigstore bundle (certificate + signature + transparency-log proof) that is +# uploaded with the release as checksums.txt.sigstore.json. Verify with: +# +# cosign verify-blob --bundle checksums.txt.sigstore.json \ +# --certificate-identity "https://github.com/GrayCodeAI/rho/.github/workflows/release.yml@refs/tags/vX.Y.Z" \ +# --certificate-oidc-issuer https://token.actions.githubusercontent.com \ +# checksums.txt +# --------------------------------------------------------------------------- +signs: + - id: cosign-checksum + cmd: cosign + artifacts: checksum + signature: "${artifact}.sigstore.json" + args: + - sign-blob + - "--bundle=${signature}" + - "${artifact}" + - "--yes" + # --------------------------------------------------------------------------- # Snapshot — unreleased dev builds get a clear synthetic version. # --------------------------------------------------------------------------- @@ -83,7 +116,9 @@ snapshot: version_template: "{{ incpatch .Version }}-next" # --------------------------------------------------------------------------- -# Changelog — Conventional-Commit grouped, hidden noise. +# Changelog — used for snapshots only. Tagged releases take their notes from +# the CHANGELOG.md section for the version (release.yml passes +# --release-notes), so the published notes match the curated changelog. # --------------------------------------------------------------------------- changelog: sort: asc @@ -116,26 +151,14 @@ changelog: - title: "Other" order: 999 -# --------------------------------------------------------------------------- # Homebrew and npm are intentionally not configured: neither the # GrayCodeAI/homebrew-tap repository nor the @graycodeai npm packages exist. -# --------------------------------------------------------------------------- - -# --------------------------------------------------------------------------- -# Release — auto-detect prereleases (rc/beta tags). Created on the repo -# itself (not a separate release repo). -# --------------------------------------------------------------------------- -sboms: - - artifacts: archive - documents: - - "${artifact}.spdx.sbom.json" +# See docs/RELEASING.md ("Distribution channels") before adding either. # --------------------------------------------------------------------------- -# Signing — cosign keyless signing of checksums is performed in the -# release workflow (post-goreleaser) so the OIDC token from GitHub Actions -# is available. The checksums.txt artifact is signed there. +# Release — GitHub release on this repository; rc/beta tags are marked as +# prereleases automatically. # --------------------------------------------------------------------------- - release: draft: false prerelease: auto @@ -143,7 +166,20 @@ release: header: | ## rho v{{ .Version }} - AI coding agent — reads, writes, and runs code in your terminal + Rho is the terminal AI coding agent from GrayCode (pre-1.0). footer: | - **Full changelog:** https://github.com/GrayCodeAI/rho/compare/{{ .PreviousTag }}...{{ .Tag }} + ### Verify this release + + `checksums.txt` lists the SHA-256 of every asset and is signed with + cosign keyless signing by this repository's release workflow: + + ```sh + cosign verify-blob --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/GrayCodeAI/rho/.github/workflows/release.yml@refs/tags/{{ .Tag }}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums.txt + sha256sum --ignore-missing -c checksums.txt + ``` + + {{ if .PreviousTag }}**Full changelog:** https://github.com/GrayCodeAI/rho/compare/{{ .PreviousTag }}...{{ .Tag }}{{ end }} diff --git a/.shared-templates/.goreleaser.yml.tmpl b/.shared-templates/.goreleaser.yml.tmpl index fc9db117f..eec7aedf3 100644 --- a/.shared-templates/.goreleaser.yml.tmpl +++ b/.shared-templates/.goreleaser.yml.tmpl @@ -1,29 +1,30 @@ # Canonical GrayCodeAI goreleaser config for Go binary repos. -# Source of truth: .shared-templates/.goreleaser.yml.tmpl +# Source of truth: .shared-templates/.goreleaser.yml.tmpl (rho's +# .goreleaser.yml is the reference rendering). # # Placeholders rendered per repo (ALL-CAPS, distinct from goreleaser's own # `{{.Version}}`-style Go template fields, which are left as-is below): -# {{NAME}} — short repo name (e.g. rho, harrier, swift) -# {{MAIN_PKG}} — main package path (e.g. ./ or ./cmd/harrier) -# {{DESCRIPTION}} — short single-line description for brew/nfpms +# {{NAME}} — short repo name (e.g. rho) +# {{MAIN_PKG}} — main package path (e.g. ./ or ./cmd/rho) +# {{DESCRIPTION}} — short single-line description for the release header # {{VERSION_PKG}} — Go package path holding Version vars -# (e.g. main or github.com/GrayCodeAI/rho/internal/version) +# (e.g. main or github.com/GrayCodeAI//internal/version) # -# Repos with PRO/special features (e.g. swift's macOS notarization, shrike's -# nfpms) extend this template with extra sections instead of replacing it. +# The release workflow template (workflows/go-release.yml.tmpl) installs the +# syft and cosign binaries this config shells out to. version: 2 project_name: {{NAME}} # --------------------------------------------------------------------------- -# Pre-build hooks — keep go.mod tidy and verified. +# Pre-build hooks — verify module checksums before building anything. # --------------------------------------------------------------------------- before: hooks: - go mod verify # --------------------------------------------------------------------------- -# Builds — three OS × two arch (no Windows/arm64). +# Builds — linux/darwin/windows × amd64/arm64 (six targets). # Reproducible: `mod_timestamp` ties the binary timestamp to the commit time # rather than the build host's clock. # --------------------------------------------------------------------------- @@ -33,6 +34,10 @@ builds: binary: {{NAME}} env: - CGO_ENABLED=0 + # Build strictly from go.mod/go.sum, never from a developer go.work. + - GOWORK=off + flags: + - -trimpath goos: - linux - darwin @@ -40,9 +45,6 @@ builds: goarch: - amd64 - arm64 - ignore: - - goos: windows - goarch: arm64 ldflags: - -s -w - -X {{VERSION_PKG}}.Version={{.Version}} @@ -51,7 +53,8 @@ builds: mod_timestamp: "{{ .CommitTimestamp }}" # --------------------------------------------------------------------------- -# Archives — tar.gz on Unix, zip on Windows. Includes README + LICENSE. +# Archives — tar.gz on Unix, zip on Windows. Names are +# ___.; installers depend on this exact shape. # --------------------------------------------------------------------------- archives: - id: default @@ -74,12 +77,43 @@ source: name_template: "{{ .ProjectName }}_{{ .Version }}_source" # --------------------------------------------------------------------------- -# Checksums — SHA-256, single file per release. +# SBOMs — one SPDX document per archive, generated with syft. The release +# workflow installs syft before GoReleaser runs. +# --------------------------------------------------------------------------- +sboms: + - artifacts: archive + documents: + - "${artifact}.spdx.sbom.json" + +# --------------------------------------------------------------------------- +# Checksums — SHA-256 over every uploaded artifact, single file per release. # --------------------------------------------------------------------------- checksum: name_template: checksums.txt algorithm: sha256 +# --------------------------------------------------------------------------- +# Signing — cosign keyless (Sigstore) signature over checksums.txt, using the +# release workflow's GitHub OIDC identity. The output is a self-contained +# Sigstore bundle (certificate + signature + transparency-log proof) that is +# uploaded with the release as checksums.txt.sigstore.json. Verify with: +# +# cosign verify-blob --bundle checksums.txt.sigstore.json \ +# --certificate-identity "https://github.com/GrayCodeAI/{{NAME}}/.github/workflows/release.yml@refs/tags/vX.Y.Z" \ +# --certificate-oidc-issuer https://token.actions.githubusercontent.com \ +# checksums.txt +# --------------------------------------------------------------------------- +signs: + - id: cosign-checksum + cmd: cosign + artifacts: checksum + signature: "${artifact}.sigstore.json" + args: + - sign-blob + - "--bundle=${signature}" + - "${artifact}" + - "--yes" + # --------------------------------------------------------------------------- # Snapshot — unreleased dev builds get a clear synthetic version. # --------------------------------------------------------------------------- @@ -87,7 +121,9 @@ snapshot: version_template: "{{ incpatch .Version }}-next" # --------------------------------------------------------------------------- -# Changelog — Conventional-Commit grouped, hidden noise. +# Changelog — used for snapshots only. Tagged releases take their notes from +# the CHANGELOG.md section for the version (release.yml passes +# --release-notes), so the published notes match the curated changelog. # --------------------------------------------------------------------------- changelog: sort: asc @@ -120,23 +156,34 @@ changelog: - title: "Other" order: 999 +# No Homebrew/npm/nfpm publishers: add one only when its destination and +# credentials exist, or the release fails after the GitHub release is created. + # --------------------------------------------------------------------------- -# SBOM + release — auto-detect prereleases (rc/beta tags). Created on the -# repo itself (not a separate release repo). +# Release — GitHub release on this repository; rc/beta tags are marked as +# prereleases automatically. # --------------------------------------------------------------------------- -sboms: - - artifacts: archive - documents: - - "${artifact}.spdx.sbom.json" - release: draft: false prerelease: auto name_template: "v{{ .Version }}" header: | - ## {{NAME}} v{{.Version}} + ## {{NAME}} v{{ .Version }} {{DESCRIPTION}} footer: | - **Full changelog:** https://github.com/GrayCodeAI/{{NAME}}/compare/{{.PreviousTag}}...{{.Tag}} + ### Verify this release + + `checksums.txt` lists the SHA-256 of every asset and is signed with + cosign keyless signing by this repository's release workflow: + + ```sh + cosign verify-blob --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/GrayCodeAI/{{NAME}}/.github/workflows/release.yml@refs/tags/{{ .Tag }}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums.txt + sha256sum --ignore-missing -c checksums.txt + ``` + + {{ if .PreviousTag }}**Full changelog:** https://github.com/GrayCodeAI/{{NAME}}/compare/{{ .PreviousTag }}...{{ .Tag }}{{ end }} diff --git a/.shared-templates/workflows/go-release.yml.tmpl b/.shared-templates/workflows/go-release.yml.tmpl index a8ac6ea00..d97a444b6 100644 --- a/.shared-templates/workflows/go-release.yml.tmpl +++ b/.shared-templates/workflows/go-release.yml.tmpl @@ -1,6 +1,8 @@ -# Canonical release workflow for GrayCodeAI Go binary repos. -# Triggered by release-please when it pushes a v* tag. +# Canonical release workflow for GrayCodeAI Go binary repos (GoReleaser). +# A maintainer pushes a v tag after the release PR (VERSION + +# CHANGELOG) merges; protect v* tags with a repository ruleset. # Source of truth: .shared-templates/workflows/go-release.yml.tmpl +# (rho's .github/workflows/release.yml is the reference rendering). name: release @@ -9,29 +11,50 @@ on: tags: ["v*"] permissions: - contents: write - packages: write - id-token: write # for cosign keyless signing if enabled later + contents: write # create the GitHub release and upload its assets + id-token: write # cosign keyless signing (GitHub OIDC -> Sigstore Fulcio) + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false jobs: goreleaser: runs-on: ubuntu-latest + env: + GOPROXY: "https://proxy.golang.org,direct" + # Release builds resolve every dependency from go.mod/go.sum through + # the public proxy — never from sibling checkouts or a go.work. + GOWORK: "off" steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # goreleaser needs full history for changelog + persist-credentials: false - # Only needed if this repo has local workspace dependencies on other - # graycode-eco repos at build time (rho itself clones flux this way via - # ./.github/actions/checkout-flux). Omit for repos with none. + # Refuses a tag that disagrees with VERSION or has no CHANGELOG section, + # and extracts that section as the release notes. Copy + # scripts/check-release-tag.sh from rho. + - name: Check tag against VERSION and CHANGELOG + run: bash ./scripts/check-release-tag.sh --notes "${RUNNER_TEMP}/release-notes.md" "${GITHUB_REF_NAME}" - name: Set up Go uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: - go-version: "1.26.5" + go-version: "1.26.6" cache: true + # GoReleaser shells out to syft for the `sboms:` stanza; the runner image + # does not ship it. + - name: Install syft + uses: anchore/sbom-action/download-syft@3ad7283483fc7af8ff2b4ea19663c2d5ca935e26 # v0.24.2 + + # GoReleaser shells out to cosign for the `signs:` stanza (keyless + # signature over checksums.txt, uploaded as checksums.txt.sigstore.json). + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Run GoReleaser uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 with: @@ -39,9 +62,23 @@ jobs: # Must match a real goreleaser release — verify at # https://github.com/goreleaser/goreleaser/releases before bumping. version: "v2.17.0" - args: release --clean + args: release --clean --release-notes=${{ runner.temp }}/release-notes.md env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Optional secrets used by some repos' goreleaser configs: - HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} - TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }} + + - name: Verify the published signature + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + # Re-download what users will download and verify it the way + # install.sh does, so a broken signature fails this run loudly. + verify_dir="$(mktemp -d)" + gh release download "${GITHUB_REF_NAME}" --repo "${GITHUB_REPOSITORY}" \ + --pattern checksums.txt --pattern checksums.txt.sigstore.json \ + --dir "${verify_dir}" + cosign verify-blob \ + --bundle "${verify_dir}/checksums.txt.sigstore.json" \ + --certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/release.yml@${GITHUB_REF}" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + "${verify_dir}/checksums.txt" diff --git a/internal/testaudit/release_workflow_test.go b/internal/testaudit/release_workflow_test.go new file mode 100644 index 000000000..c0a3bc602 --- /dev/null +++ b/internal/testaudit/release_workflow_test.go @@ -0,0 +1,249 @@ +package testaudit + +import ( + "bytes" + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "slices" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// Release pipeline invariants: the files that together produce, sign and +// install a rho release must agree with each other. See docs/RELEASING.md. + +func TestGoreleaserConfigShipsVerifiableArchives(t *testing.T) { + var cfg struct { + ProjectName string `yaml:"project_name"` + Builds []struct { + Main string `yaml:"main"` + Binary string `yaml:"binary"` + Goos []string `yaml:"goos"` + Goarch []string `yaml:"goarch"` + Ignore []any `yaml:"ignore"` + } `yaml:"builds"` + Archives []struct { + NameTemplate string `yaml:"name_template"` + } `yaml:"archives"` + Checksum struct { + NameTemplate string `yaml:"name_template"` + } `yaml:"checksum"` + Signs []struct { + Cmd string `yaml:"cmd"` + Artifacts string `yaml:"artifacts"` + Signature string `yaml:"signature"` + Args []string `yaml:"args"` + } `yaml:"signs"` + Sboms []any `yaml:"sboms"` + } + raw := readRepoFile(t, ".goreleaser.yml") + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatalf("parse .goreleaser.yml: %v", err) + } + var top map[string]any + if err := yaml.Unmarshal(raw, &top); err != nil { + t.Fatalf("parse .goreleaser.yml: %v", err) + } + // Neither the Homebrew tap nor the npm packages exist; publishing to them + // would fail the release after the GitHub release is created. + for _, key := range []string{"brews", "homebrew_casks", "nfpms", "npms"} { + if _, ok := top[key]; ok { + t.Errorf(".goreleaser.yml configures %q, but that channel does not exist (see docs/RELEASING.md)", key) + } + } + + if cfg.ProjectName != "rho" { + t.Errorf("project_name = %q, want rho (install.sh downloads rho___)", cfg.ProjectName) + } + if len(cfg.Builds) != 1 || cfg.Builds[0].Main != "./cmd/rho" || cfg.Builds[0].Binary != "rho" { + t.Fatalf("want exactly one build of ./cmd/rho named rho, got %+v", cfg.Builds) + } + b := cfg.Builds[0] + if strings.Join(b.Goos, ",") != "linux,darwin,windows" || strings.Join(b.Goarch, ",") != "amd64,arm64" || len(b.Ignore) != 0 { + t.Errorf("builds must cover linux/darwin/windows × amd64/arm64 with no ignores (README promises all six), got goos=%v goarch=%v ignore=%v", b.Goos, b.Goarch, b.Ignore) + } + if len(cfg.Archives) != 1 || strings.TrimSpace(cfg.Archives[0].NameTemplate) != "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" { + t.Errorf("archive name_template changed; install.sh and .github/actions/rho depend on rho___: %+v", cfg.Archives) + } + if cfg.Checksum.NameTemplate != "checksums.txt" { + t.Errorf("checksum name = %q, want checksums.txt", cfg.Checksum.NameTemplate) + } + if len(cfg.Sboms) == 0 { + t.Error("sboms stanza missing; release.yml installs syft for it") + } + if len(cfg.Signs) != 1 { + t.Fatalf("want one signs entry (cosign over checksums.txt), got %d", len(cfg.Signs)) + } + s := cfg.Signs[0] + if s.Cmd != "cosign" || s.Artifacts != "checksum" || s.Signature != "${artifact}.sigstore.json" { + t.Errorf("signs = %+v, want cosign over the checksum artifact producing ${artifact}.sigstore.json", s) + } + if !slices.Contains(s.Args, "sign-blob") || !slices.Contains(s.Args, "--bundle=${signature}") { + t.Errorf("cosign args = %q, want sign-blob --bundle=${signature}", s.Args) + } + + install := string(readRepoFile(t, "install.sh")) + for _, want := range []string{ + "checksums.txt.sigstore.json", + `${BINARY}_${VERSION}_${OS}_${ARCH}.${ARCHIVE_EXT}`, + ".github/workflows/release.yml@refs/tags/", + } { + if !strings.Contains(install, want) { + t.Errorf("install.sh does not reference %q; it must match .goreleaser.yml and release.yml", want) + } + } +} + +func TestReleaseWorkflowToolingAndPermissions(t *testing.T) { + var wf struct { + Permissions map[string]string `yaml:"permissions"` + Jobs map[string]struct { + Steps []struct { + Name string `yaml:"name"` + Uses string `yaml:"uses"` + Run string `yaml:"run"` + With map[string]string `yaml:"with"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + raw := readRepoFile(t, ".github/workflows/release.yml") + if err := yaml.Unmarshal(raw, &wf); err != nil { + t.Fatalf("parse release.yml: %v", err) + } + wantPerms := map[string]string{"contents": "write", "id-token": "write"} + if len(wf.Permissions) != len(wantPerms) { + t.Errorf("release.yml permissions = %v, want exactly %v", wf.Permissions, wantPerms) + } + for k, v := range wantPerms { + if wf.Permissions[k] != v { + t.Errorf("release.yml permissions[%s] = %q, want %q", k, wf.Permissions[k], v) + } + } + job, ok := wf.Jobs["goreleaser"] + if !ok { + t.Fatal("release.yml has no goreleaser job") + } + index := func(pred func(i int) bool) int { + for i := range job.Steps { + if pred(i) { + return i + } + } + return -1 + } + usesPrefix := func(prefix string) int { + return index(func(i int) bool { return strings.HasPrefix(job.Steps[i].Uses, prefix) }) + } + goreleaser := usesPrefix("goreleaser/goreleaser-action@") + syft := usesPrefix("anchore/sbom-action/download-syft@") + cosign := usesPrefix("sigstore/cosign-installer@") + guard := index(func(i int) bool { return strings.Contains(job.Steps[i].Run, "scripts/check-release-tag.sh") }) + switch { + case goreleaser < 0: + t.Fatal("release.yml does not run goreleaser") + case syft < 0 || syft > goreleaser: + t.Error("syft must be installed before GoReleaser (the sboms stanza shells out to it)") + case cosign < 0 || cosign > goreleaser: + t.Error("cosign must be installed before GoReleaser (the signs stanza shells out to it)") + case guard < 0 || guard > goreleaser: + t.Error("scripts/check-release-tag.sh must run before GoReleaser") + } + if args := job.Steps[max(goreleaser, 0)].With["args"]; !strings.Contains(args, "--release-notes=") { + t.Errorf("goreleaser args = %q, want --release-notes from the CHANGELOG section", args) + } + for _, step := range job.Steps { + if strings.Contains(step.Uses, "checkout-flux") { + t.Error("release.yml must build from go.mod/go.sum, not sibling checkouts") + } + if strings.Contains(step.Run, "-D dist") || strings.Contains(step.Run, "--dir dist") { + t.Errorf("step %q downloads into goreleaser's dist/ directory", step.Name) + } + } + for _, pinned := range []int{goreleaser, syft, cosign} { + if pinned >= 0 && !regexp.MustCompile(`@[0-9a-f]{40}$`).MatchString(job.Steps[pinned].Uses) { + t.Errorf("%s is not pinned to a commit SHA", job.Steps[pinned].Uses) + } + } + if bytes.Contains(raw, []byte("release-please")) || bytes.Contains(raw, []byte("HOMEBREW_TAP_TOKEN")) { + t.Error("release.yml references release-please or Homebrew tap tokens that do not exist") + } +} + +func TestReleaseTagGuard(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("scripts/check-release-tag.sh is a bash script; covered on Linux and macOS") + } + bash, err := exec.LookPath("bash") + if err != nil { + t.Skipf("bash not available: %v", err) + } + script := filepath.Join(repoRoot(t), "scripts", "check-release-tag.sh") + run := func(t *testing.T, version, changelog string, args ...string) (int, string) { + t.Helper() + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "VERSION"), []byte(version+"\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "CHANGELOG.md"), []byte(changelog), 0o644); err != nil { + t.Fatal(err) + } + cmd := exec.Command(bash, append([]string{script}, args...)...) + cmd.Env = append(os.Environ(), "RELEASE_CHECK_ROOT="+root) + out, err := cmd.CombinedOutput() + var exitErr *exec.ExitError + switch { + case err == nil: + return 0, string(out) + case errors.As(err, &exitErr): + return exitErr.ExitCode(), string(out) + default: + t.Fatalf("run guard: %v", err) + return -1, "" + } + } + const good = "# Changelog\n\n## [Unreleased]\n\n## [0.3.0] — 2026-09-30\n\n### Added\n- first rho release\n\n## [hawk 0.2.0] — 2026-07-13\n\n- old\n" + + t.Run("matching tag writes notes", func(t *testing.T) { + notes := filepath.Join(t.TempDir(), "notes.md") + code, out := run(t, "0.3.0", good, "--notes", notes, "v0.3.0") + if code != 0 { + t.Fatalf("exit %d: %s", code, out) + } + got, err := os.ReadFile(notes) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "- first rho release") || strings.Contains(string(got), "hawk") { + t.Errorf("notes should hold only the 0.3.0 section, got:\n%s", got) + } + }) + failures := []struct { + name, version, changelog, tag, want string + }{ + {"tag differs from VERSION", "0.3.0", good, "v0.3.1", "does not match VERSION"}, + {"tag without v", "0.3.0", good, "0.3.0", "does not match VERSION"}, + {"VERSION not semver", "0.3", good, "v0.3", "not a SemVer version"}, + {"no changelog section", "0.3.0", "# Changelog\n\n## [Unreleased]\n\n- pending\n", "v0.3.0", "no non-empty '## [0.3.0]' section"}, + {"empty changelog section", "0.3.0", "## [Unreleased]\n\n## [0.3.0] — 2026-09-30\n\n## [hawk 0.2.0]\n- old\n", "v0.3.0", "no non-empty"}, + {"dot is not a wildcard", "0.3.0", "## [0x3x0] — 2026-09-30\n\n- wrong\n", "v0.3.0", "no non-empty"}, + } + for _, tc := range failures { + t.Run(tc.name, func(t *testing.T) { + code, out := run(t, tc.version, tc.changelog, tc.tag) + if code == 0 || !strings.Contains(out, tc.want) { + t.Fatalf("want failure containing %q, got exit %d: %s", tc.want, code, out) + } + }) + } + t.Run("usage", func(t *testing.T) { + if code, out := run(t, "0.3.0", good); code != 2 { + t.Fatalf("want usage exit 2, got %d: %s", code, out) + } + }) +} diff --git a/scripts/check-release-tag.sh b/scripts/check-release-tag.sh new file mode 100755 index 000000000..a87e4d5ee --- /dev/null +++ b/scripts/check-release-tag.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Release guard: refuse to release a tag that disagrees with the repository. +# +# Usage: scripts/check-release-tag.sh [--notes ] (e.g. v0.3.0) +# +# Checks, relative to the repository root (or RELEASE_CHECK_ROOT when set): +# 1. VERSION holds a SemVer 2.0.0 version (no leading "v"). +# 2. is exactly "v" + VERSION. +# 3. CHANGELOG.md has a non-empty "## []" section. +# With --notes, that section's body is written to for use as the +# GitHub release notes. +# +# Run by .github/workflows/release.yml before GoReleaser and by +# `make release-check`. See docs/RELEASING.md. +set -euo pipefail + +notes_file="" +if [ "${1:-}" = "--notes" ]; then + if [ "$#" -lt 2 ] || [ -z "$2" ]; then + echo "usage: $0 [--notes ] " >&2 + exit 2 + fi + notes_file="$2" + shift 2 +fi +if [ "$#" -ne 1 ] || [ -z "$1" ]; then + echo "usage: $0 [--notes ] (for example: $0 v0.3.0)" >&2 + exit 2 +fi +tag="$1" + +root="${RELEASE_CHECK_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +if [ ! -f "${root}/VERSION" ]; then + echo "ERROR: ${root}/VERSION not found" >&2 + exit 1 +fi +version="$(tr -d '[:space:]' < "${root}/VERSION")" + +semver_re='^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' +if ! [[ "${version}" =~ ${semver_re} ]]; then + echo "ERROR: VERSION '${version}' is not a SemVer version like 1.2.3 (no leading 'v')" >&2 + exit 1 +fi + +if [ "${tag}" != "v${version}" ]; then + echo "ERROR: tag '${tag}' does not match VERSION '${version}' (expected tag 'v${version}')." >&2 + echo " Bump VERSION in a release PR, merge it, then tag that commit." >&2 + exit 1 +fi + +if [ ! -f "${root}/CHANGELOG.md" ]; then + echo "ERROR: ${root}/CHANGELOG.md not found" >&2 + exit 1 +fi +# Print the body of the "## []" section: literal prefix match on the +# heading (so '.' is not a wildcard), up to the next "## " heading. +section="$(awk -v want="## [${version}]" ' + index($0, want) == 1 { in_section = 1; next } + in_section && /^## / { exit } + in_section { print } +' "${root}/CHANGELOG.md")" +if [ -z "$(printf '%s' "${section}" | tr -d '[:space:]')" ]; then + echo "ERROR: CHANGELOG.md has no non-empty '## [${version}]' section." >&2 + echo " Move the [Unreleased] notes under '## [${version}] — YYYY-MM-DD' in the release PR." >&2 + exit 1 +fi +if [ -n "${notes_file}" ]; then + printf '%s\n' "${section}" > "${notes_file}" +fi + +echo "OK: tag ${tag} matches VERSION ${version} and CHANGELOG.md has a [${version}] section." From 712b38558ca5225e85b58fbc80e13b6ee4dcf086 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:47:11 +0530 Subject: [PATCH 05/16] fix(install): verify releases fail-closed and parse flags strictly install.sh could not install anything and was weaker than it claimed: - It fetched rho__* from releases/latest (v0.2.0), whose assets are hawk_*: every install 404ed. Releases before v0.3.0 predate the rename, so they are now refused with a clear message and no download (no hawk_* fallback: those archives contain a `hawk` binary). - Signature: it looked for checksums.txt.sig/.cert, which no release has, and matched the certificate with an unanchored regexp built from the unescaped version ("anchor and escape" said the comment). It now verifies the Sigstore bundle checksums.txt.sigstore.json that the release workflow uploads, with the exact --certificate-identity https://github.com//.github/workflows/release.yml@refs/tags/v. When cosign is present, a missing or invalid signature aborts. When it is absent the script prints a prominent warning and reports "signature: NOT verified" in the summary; RHO_REQUIRE_COSIGN=1 makes cosign mandatory. - The SHA-256 check always runs (sha256sum, shasum or openssl) and requires exactly one exact-name entry in checksums.txt. - Flags are parsed in a loop: --version/--prefix in any order, the --flag=value forms, --help, and exit 2 on unknown or empty flags (previously `--version X --prefix Y` silently ignored the prefix). - The version is validated before use, unsupported OS/arch fail fast, temp files are cleaned on exit/signals, and RHO_INSTALL_REPO (validated owner/repo) lets a fork install its own signed releases. internal/testaudit/install_script_test.go runs the real script end to end against a fake release served by stub curl/uname/cosign, with PATH restricted so the host's tools cannot change the outcome. Refs: F019, F029, F260 Co-Authored-By: Claude Opus 5.5 --- install.sh | 416 ++++++++++------ internal/testaudit/install_script_test.go | 581 ++++++++++++++++++++++ 2 files changed, 836 insertions(+), 161 deletions(-) create mode 100644 internal/testaudit/install_script_test.go diff --git a/install.sh b/install.sh index cc9194809..a73380ab0 100755 --- a/install.sh +++ b/install.sh @@ -1,192 +1,286 @@ #!/bin/sh -set -e - -# Versioned install target. -# By default the binary is installed under $RHO_HOME/bin (default ~/.rho/bin). -# Override with RHO_HOME env var, or pass --prefix as the first flag. -RHO_HOME="${RHO_HOME:-$HOME/.rho}" -if [ "$1" = "--prefix" ]; then - RHO_HOME="$2" - shift 2 -fi +# Install rho (the GrayCode terminal AI coding agent) from a GitHub release. +# +# curl -fsSL https://raw.githubusercontent.com/GrayCodeAI/rho/v0.3.0/install.sh | sh +# curl -fsSL https://raw.githubusercontent.com/GrayCodeAI/rho/v0.3.0/install.sh | sh -s -- --version 0.3.0 --prefix "$HOME/.rho" +# +# Steps: +# 1. Resolve the version: --version / RHO_VERSION, otherwise the latest +# GitHub release. Releases before v0.3.0 predate the rename to rho (they +# ship hawk_* archives) and are refused. +# 2. Download rho___.tar.gz (.zip on Windows) and +# checksums.txt from the release. +# 3. When cosign is installed, verify checksums.txt against the release's +# Sigstore bundle (checksums.txt.sigstore.json) with the exact identity of +# this repository's release workflow for that tag. A missing or invalid +# signature aborts the install. Without cosign the signature is NOT +# verified and the script says so; RHO_REQUIRE_COSIGN=1 makes cosign +# mandatory. +# 4. Always verify the archive's SHA-256 against checksums.txt. +# 5. Install /bin/rho- and atomically point /bin/rho +# at it (a copy named rho.exe on Windows). +# +# Options: +# --version install this version (e.g. 0.3.0 or v0.3.0) +# --prefix install under /bin (default: $RHO_HOME or ~/.rho) +# -h, --help show this help +# +# Environment: +# RHO_VERSION= same as --version (the flag wins) +# RHO_HOME= same as --prefix (the flag wins) +# RHO_REQUIRE_COSIGN=1 fail unless the cosign signature can be verified +# RHO_INSTALL_REPO= install from a fork's releases (default GrayCodeAI/rho); +# the cosign identity follows the chosen repository +set -eu -REPO="GrayCodeAI/rho" +MIN_VERSION="0.3.0" BINARY="rho" +OIDC_ISSUER="https://token.actions.githubusercontent.com" +REPO="${RHO_INSTALL_REPO:-GrayCodeAI/rho}" + +say() { printf '%s\n' "$*"; } +warn() { printf 'WARNING: %s\n' "$*" >&2; } +die() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +usage() { + cat <<'EOF' +Install rho from a GitHub release. + +Usage: install.sh [--version ] [--prefix ] + + --version install this version (e.g. 0.3.0); default: latest release + --prefix install under /bin; default: $RHO_HOME or ~/.rho + -h, --help show this help + +Environment: RHO_VERSION, RHO_HOME, RHO_REQUIRE_COSIGN=1, RHO_INSTALL_REPO. +EOF +} + +usage_error() { + printf 'Error: %s\n\n' "$*" >&2 + usage >&2 + exit 2 +} + +# fetch URL DEST — HTTPS only, fail on HTTP errors. +fetch() { + curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$2" "$1" +} -# Version pin: --version or RHO_VERSION env (e.g. 0.1.0 or v0.1.0) +sha256_of() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 "$1" | awk '{print $NF}' + else + die "need sha256sum, shasum or openssl to verify the download" + fi +} + +# version_core_ge A B — true when MAJOR.MINOR.PATCH of A >= that of B. +version_core_ge() { + awk -v a="$1" -v b="$2" 'BEGIN { + split(a, x, /[.+-]/); split(b, y, /[.+-]/) + for (i = 1; i <= 3; i++) { + if (x[i] + 0 > y[i] + 0) exit 0 + if (x[i] + 0 < y[i] + 0) exit 1 + } + exit 0 + }' +} + +# --- Arguments ----------------------------------------------------------------- VERSION_PIN="${RHO_VERSION:-}" -if [ "$1" = "--version" ]; then - VERSION_PIN="$2" - shift 2 -elif [ "$3" = "--version" ]; then - # handle --prefix --version order - VERSION_PIN="$4" - set -- "$1" "$2" +PREFIX="${RHO_HOME:-}" +while [ $# -gt 0 ]; do + case "$1" in + --version) + [ $# -ge 2 ] && [ -n "$2" ] || usage_error "--version needs a value" + VERSION_PIN=$2 + shift 2 + ;; + --version=*) + VERSION_PIN=${1#--version=} + [ -n "$VERSION_PIN" ] || usage_error "--version needs a value" + shift + ;; + --prefix) + [ $# -ge 2 ] && [ -n "$2" ] || usage_error "--prefix needs a directory" + PREFIX=$2 + shift 2 + ;; + --prefix=*) + PREFIX=${1#--prefix=} + [ -n "$PREFIX" ] || usage_error "--prefix needs a directory" + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + usage_error "unknown argument: $1" + ;; + esac +done + +if [ -z "$PREFIX" ]; then + [ -n "${HOME:-}" ] || die "HOME is not set; pass --prefix " + PREFIX="$HOME/.rho" fi -VERSION_PIN=$(printf '%s' "$VERSION_PIN" | sed 's/^v//') -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) -case "$ARCH" in - x86_64) ARCH="amd64" ;; - aarch64|arm64) ARCH="arm64" ;; -esac +if ! printf '%s' "$REPO" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then + die "RHO_INSTALL_REPO must look like owner/repo (got: $REPO)" +fi -ARCHIVE_EXT="tar.gz" -BIN_NAME="$BINARY" +command -v curl >/dev/null 2>&1 || die "curl is required" -case "$OS" in - mingw*|msys*|cygwin*) - OS="windows" - ARCHIVE_EXT="zip" - BIN_NAME="${BINARY}.exe" - ;; +# --- Platform -------------------------------------------------------------------- +os=$(uname -s | tr '[:upper:]' '[:lower:]') +case "$os" in + linux) OS=linux ;; + darwin) OS=darwin ;; + mingw* | msys* | cygwin*) OS=windows ;; + *) die "unsupported operating system: $os (release archives exist for linux, darwin and windows)" ;; +esac +arch=$(uname -m) +case "$arch" in + x86_64 | amd64) ARCH=amd64 ;; + aarch64 | arm64) ARCH=arm64 ;; + *) die "unsupported architecture: $arch (release archives exist for amd64 and arm64)" ;; esac +if [ "$OS" = windows ]; then + ARCHIVE_EXT=zip + BIN_NAME="${BINARY}.exe" +else + ARCHIVE_EXT=tar.gz + BIN_NAME="$BINARY" +fi +# --- Version --------------------------------------------------------------------- if [ -n "$VERSION_PIN" ]; then - LATEST="$VERSION_PIN" + VERSION=${VERSION_PIN#v} else - LATEST=$(curl -fsSL --proto '=https' --tlsv1.2 "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') - if [ -z "$LATEST" ]; then - echo "Error: could not determine latest version" - exit 1 - fi + latest_json=$(curl -fsSL --proto '=https' --tlsv1.2 --retry 3 \ + -H 'Accept: application/vnd.github+json' \ + "https://api.github.com/repos/${REPO}/releases/latest") || + die "could not query the latest release of ${REPO}; pass --version " + tag=$(printf '%s\n' "$latest_json" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) + [ -n "$tag" ] || die "could not determine the latest release of ${REPO}; pass --version " + VERSION=${tag#v} +fi + +if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + die "invalid version '$VERSION' (expected MAJOR.MINOR.PATCH, e.g. 0.3.0)" +fi +if ! version_core_ge "$VERSION" "$MIN_VERSION"; then + die "v${VERSION} predates the rename to rho: releases before v${MIN_VERSION} ship hawk_* archives that this script cannot install. Install v${MIN_VERSION} or later, or build from source (https://github.com/${REPO}#readme)." fi -ARCHIVE_NAME="${BINARY}_${LATEST}_${OS}_${ARCH}.${ARCHIVE_EXT}" -URL="https://github.com/$REPO/releases/download/v${LATEST}/${ARCHIVE_NAME}" -echo "Downloading rho v${LATEST} for ${OS}/${ARCH}..." - -TMP=$(mktemp -d) -ARCHIVE="$TMP/${ARCHIVE_NAME}" -curl -fsSL --proto '=https' --tlsv1.2 "$URL" -o "$ARCHIVE" - -CHECKSUMS_URL="https://github.com/$REPO/releases/download/v${LATEST}/checksums.txt" -CHECKSUMS="$TMP/checksums.txt" -curl -fsSL --proto '=https' --tlsv1.2 "$CHECKSUMS_URL" -o "$CHECKSUMS" - -# Verify the checksums.txt signature with cosign if available. This protects -# against a compromised release (not just transport corruption). When cosign -# is not installed, we fall back to checksum-only verification. -CERT_URL="https://github.com/$REPO/releases/download/v${LATEST}/checksums.txt.cert" -SIG_URL="https://github.com/$REPO/releases/download/v${LATEST}/checksums.txt.sig" -CERT_FILE="$TMP/checksums.txt.cert" -SIG_FILE="$TMP/checksums.txt.sig" +TAG="v${VERSION}" +BASE_URL="https://github.com/${REPO}/releases/download/${TAG}" +ARCHIVE_NAME="${BINARY}_${VERSION}_${OS}_${ARCH}.${ARCHIVE_EXT}" + +TMP=$(mktemp -d 2>/dev/null || mktemp -d -t rho-install) +trap 'rm -rf "$TMP"' EXIT +trap 'exit 130' INT +trap 'exit 143' TERM +trap 'exit 129' HUP + +say "Downloading rho ${TAG} for ${OS}/${ARCH}..." +fetch "${BASE_URL}/${ARCHIVE_NAME}" "$TMP/$ARCHIVE_NAME" || + die "could not download ${BASE_URL}/${ARCHIVE_NAME} (does release ${TAG} exist for ${OS}/${ARCH}?)" +fetch "${BASE_URL}/checksums.txt" "$TMP/checksums.txt" || + die "could not download checksums.txt for ${TAG}" + +# --- Signature (cosign) ---------------------------------------------------------- +IDENTITY="https://github.com/${REPO}/.github/workflows/release.yml@refs/tags/${TAG}" if command -v cosign >/dev/null 2>&1; then - echo "Verifying release signature with cosign..." - if ! curl -fsSL --proto '=https' --tlsv1.2 --max-time 30 "$CERT_URL" -o "$CERT_FILE"; then - echo "Error: could not download signature certificate — refusing to install unverified release" - rm -rf "$TMP" - exit 1 - fi - if ! curl -fsSL --proto '=https' --tlsv1.2 --max-time 30 "$SIG_URL" -o "$SIG_FILE"; then - echo "Error: could not download signature file — refusing to install unverified release" - rm -rf "$TMP" - exit 1 - fi - # Anchor and escape the identity regex so '.' matches literal dots only. - IDENTITY="https://github\.com/${REPO}/\.github/workflows/release\.yml@refs/tags/v${LATEST}" + say "Verifying the checksums.txt signature with cosign..." + fetch "${BASE_URL}/checksums.txt.sigstore.json" "$TMP/checksums.txt.sigstore.json" || + die "release ${TAG} has no checksums.txt.sigstore.json signature bundle; refusing to install an unsigned release while cosign is available" if ! cosign verify-blob \ - --certificate "$CERT_FILE" \ - --signature "$SIG_FILE" \ - --certificate-identity-regexp "$IDENTITY" \ - --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ - "$CHECKSUMS"; then - echo "Error: signature verification failed — release may be compromised" - rm -rf "$TMP" - exit 1 + --new-bundle-format \ + --bundle "$TMP/checksums.txt.sigstore.json" \ + --certificate-identity "$IDENTITY" \ + --certificate-oidc-issuer "$OIDC_ISSUER" \ + "$TMP/checksums.txt" >"$TMP/cosign.log" 2>&1; then + cat "$TMP/cosign.log" >&2 + die "cosign could not verify checksums.txt for ${TAG} against ${IDENTITY}; the release may have been tampered with (cosign v2.4 or later is required, v3 recommended)" fi - echo "Signature verified." + SIGNATURE_STATUS="verified with cosign (${IDENTITY})" +elif [ "${RHO_REQUIRE_COSIGN:-0}" = 1 ]; then + die "RHO_REQUIRE_COSIGN=1 but cosign is not installed (see https://docs.sigstore.dev/cosign/system_config/installation/)" else - echo "Note: cosign not installed — install from https://docs.sigstore.dev to verify release signatures" -fi - -if command -v sha256sum >/dev/null 2>&1; then - ACTUAL=$(sha256sum "$ARCHIVE" | awk '{print $1}') -elif command -v shasum >/dev/null 2>&1; then - ACTUAL=$(shasum -a 256 "$ARCHIVE" | awk '{print $1}') -else - echo "Error: no sha256sum or shasum found; cannot verify checksum" - rm -rf "$TMP" - exit 1 -fi - -# Exact-field match (not a regex grep): avoids '.' wildcards in the archive -# name matching other lines and producing a multi-line EXPECTED value. -EXPECTED=$(awk -v f="$ARCHIVE_NAME" '$2 == f { print $1 }' "$CHECKSUMS") -if [ -z "$EXPECTED" ]; then - echo "Error: checksum not found for ${ARCHIVE_NAME} in checksums.txt" - rm -rf "$TMP" - exit 1 + warn "cosign is not installed, so the release signature was NOT verified." + warn "The SHA-256 check only detects corrupted downloads, not a tampered release." + warn "Install cosign (https://docs.sigstore.dev) or set RHO_REQUIRE_COSIGN=1 to require it." + SIGNATURE_STATUS="NOT verified (cosign not installed)" fi +# --- Checksum (always) ----------------------------------------------------------- +# Exact field match, not a regex: '.' in the archive name must not act as a +# wildcard, and exactly one line may match. +EXPECTED=$(awk -v f="$ARCHIVE_NAME" '$2 == f || $2 == "*" f { print $1 }' "$TMP/checksums.txt") +[ -n "$EXPECTED" ] || die "checksums.txt has no entry for ${ARCHIVE_NAME}" +[ "$(printf '%s\n' "$EXPECTED" | wc -l | tr -d ' ')" = 1 ] || die "checksums.txt has more than one entry for ${ARCHIVE_NAME}" +ACTUAL=$(sha256_of "$TMP/$ARCHIVE_NAME") if [ "$ACTUAL" != "$EXPECTED" ]; then - echo "Error: checksum verification failed" - echo " expected: $EXPECTED" - echo " actual: $ACTUAL" - rm -rf "$TMP" - exit 1 -fi -echo "Checksum verified." - -if [ "$OS" = "windows" ] && ! command -v unzip >/dev/null 2>&1; then - echo "Error: unzip is required to install Windows release archives" - rm -rf "$TMP" - exit 1 + printf ' expected: %s\n actual: %s\n' "$EXPECTED" "$ACTUAL" >&2 + die "SHA-256 mismatch for ${ARCHIVE_NAME}" fi +say "Checksum verified." -if [ "$OS" = "windows" ]; then - unzip -q "$ARCHIVE" -d "$TMP" +# --- Extract --------------------------------------------------------------------- +mkdir -p "$TMP/extract" +if [ "$ARCHIVE_EXT" = zip ]; then + command -v unzip >/dev/null 2>&1 || die "unzip is required to install Windows release archives" + unzip -q "$TMP/$ARCHIVE_NAME" -d "$TMP/extract" else - tar xz -C "$TMP" -f "$ARCHIVE" + tar -xzf "$TMP/$ARCHIVE_NAME" -C "$TMP/extract" fi +[ -f "$TMP/extract/$BIN_NAME" ] || die "${ARCHIVE_NAME} does not contain ${BIN_NAME}" -# Strip a leading "v" if the release tag was returned with one, so the -# versioned file name is the bare semver (e.g. 1.2.3). -VERSION=$(printf '%s' "$LATEST" | sed 's/^v//') - -# --- Versioned install ------------------------------------------------------- -# Adopted from SpaceXAI grok's postinstall.js. We never overwrite a binary in -# place. On macOS (and any codesigned platform) replacing a file that a running -# process has mmap'd invalidates the kernel's code-signature cache; the kernel -# then SIGKILLs that process. Installing into a per-version file and swapping -# the symlink means a running process keeps its open fd on the old inode and -# keeps running the previous version untouched — no SIGKILL, no disruption. -# -# The symlink is written to a temp name and then renamed into place so the -# rename is atomic; a racing process either sees the old or new link, never a -# half-written one. -# -# Release checksums are signed with cosign keyless (OIDC) in the release -# workflow. install.sh verifies the signature when cosign is available, -# falling back to checksum-only verification otherwise. Versioned install is a -# prerequisite for safe in-place self-update tooling: once installs land at a -# stable versioned path + symlink, a future updater can swap the link without -# ever replacing a binary a running rho has mmap'd (same SIGKILL rationale). -# -# Windows lacks reliable non-admin symlinks, so the launcher is a plain copy. - -BINDIR="$RHO_HOME/bin" +# --- Versioned install ----------------------------------------------------------- +# A binary is never overwritten in place. On macOS (and any codesigned +# platform) replacing a file that a running process has mmap'd invalidates the +# kernel's code-signature cache and the kernel then SIGKILLs that process. +# Installing into a per-version file and swapping a symlink keeps running rho +# processes on the old inode. The symlink is created under a temporary name and +# renamed into place so the swap is atomic. Windows lacks reliable non-admin +# symlinks, so the launcher there is a plain copy. +BINDIR="$PREFIX/bin" mkdir -p "$BINDIR" - -if [ "$OS" = "windows" ]; then - mv -f "$TMP/$BIN_NAME" "$BINDIR/rho-$VERSION.exe" - cp -f "$BINDIR/rho-$VERSION.exe" "$BINDIR/rho.exe" - echo "" - echo "Installed rho v$VERSION to $BINDIR/rho-$VERSION.exe" - echo "Linked launcher: $BINDIR/rho.exe" +if [ "$OS" = windows ]; then + mv -f "$TMP/extract/$BIN_NAME" "$BINDIR/rho-${VERSION}.exe" + cp -f "$BINDIR/rho-${VERSION}.exe" "$BINDIR/rho.exe" + INSTALLED="$BINDIR/rho-${VERSION}.exe (launcher: $BINDIR/rho.exe)" else - mv -f "$TMP/$BIN_NAME" "$BINDIR/rho-$VERSION" - ln -sf "rho-$VERSION" "$BINDIR/rho.tmp" \ - && mv -f "$BINDIR/rho.tmp" "$BINDIR/rho" - echo "" - echo "Installed rho v$VERSION to $BINDIR/rho-$VERSION (linked: $BINDIR/rho)" + mv -f "$TMP/extract/$BIN_NAME" "$BINDIR/rho-${VERSION}" + chmod 0755 "$BINDIR/rho-${VERSION}" + rm -f "$BINDIR/rho.tmp" + ln -s "rho-${VERSION}" "$BINDIR/rho.tmp" + mv -f "$BINDIR/rho.tmp" "$BINDIR/rho" + INSTALLED="$BINDIR/rho-${VERSION} (linked: $BINDIR/rho)" fi -rm -rf "$TMP" -echo "" -echo "Add $BINDIR to your PATH if it is not already, e.g." -echo " export PATH=\"\$PATH:$BINDIR\"" -echo "" -echo "Restart any running rho sessions to pick up the new binary — the old" -echo "process keeps running the previous version until it is restarted." +say "" +say "Installed rho ${TAG} to ${INSTALLED}" +say " checksum: verified (SHA-256 from checksums.txt)" +say " signature: ${SIGNATURE_STATUS}" +say "" +case ":${PATH:-}:" in + *":$BINDIR:"*) ;; + *) + say "Add $BINDIR to your PATH, e.g." + say " export PATH=\"\$PATH:$BINDIR\"" + say "" + ;; +esac +say "Restart running rho sessions to pick up the new binary; they keep running" +say "the previous version until restarted." diff --git a/internal/testaudit/install_script_test.go b/internal/testaudit/install_script_test.go new file mode 100644 index 000000000..f29a8d1a3 --- /dev/null +++ b/internal/testaudit/install_script_test.go @@ -0,0 +1,581 @@ +package testaudit + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// These tests run the real install.sh end to end against a fake GitHub release +// served by a stub `curl`, with `uname` and (optionally) `cosign` stubbed too. +// PATH is restricted to the stubs plus a curated set of system tools, so the +// presence or absence of cosign on the host never changes the outcome. + +const installTestVersion = "0.3.0" + +// installFixture is one fake release tree plus the sandboxed tool directory. +type installFixture struct { + t *testing.T + root string // repo root (install.sh lives here) + releaseDir string // //, /latest.json + stubDir string + toolDir string + prefix string + curlLog string + cosignLog string + env map[string]string +} + +type installResult struct { + exitCode int + stdout string + stderr string +} + +func (r installResult) output() string { return r.stdout + r.stderr } + +// systemTools are linked into the sandbox PATH. Missing optional tools are +// skipped; install.sh needs one of sha256sum/shasum/openssl. +var systemTools = []string{ + "sh", "awk", "sed", "grep", "tr", "mktemp", "rm", "mkdir", "mv", "cp", "ln", + "chmod", "cat", "head", "wc", "tar", "gzip", "unzip", "sha256sum", "shasum", + "openssl", "perl", "basename", "dirname", "env", +} + +func newInstallFixture(t *testing.T) *installFixture { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("install.sh is a POSIX shell script; covered on Linux and macOS") + } + for _, tool := range []string{"sh", "tar", "gzip", "awk", "sed"} { + if _, err := exec.LookPath(tool); err != nil { + t.Skipf("install.sh test needs %s on PATH: %v", tool, err) + } + } + base := t.TempDir() + f := &installFixture{ + t: t, + root: repoRoot(t), + releaseDir: filepath.Join(base, "release"), + stubDir: filepath.Join(base, "stubs"), + toolDir: filepath.Join(base, "tools"), + prefix: filepath.Join(base, "prefix"), + curlLog: filepath.Join(base, "curl.log"), + cosignLog: filepath.Join(base, "cosign.log"), + env: map[string]string{ + "FAKE_UNAME_S": "Linux", + "FAKE_UNAME_M": "x86_64", + }, + } + for _, dir := range []string{f.releaseDir, f.stubDir, f.toolDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + for _, tool := range systemTools { + path, err := exec.LookPath(tool) + if err != nil { + continue + } + if err := os.Symlink(path, filepath.Join(f.toolDir, tool)); err != nil { + t.Fatal(err) + } + } + f.writeStub("curl", fakeCurl) + f.writeStub("uname", fakeUname) + return f +} + +func (f *installFixture) writeStub(name, body string) { + f.t.Helper() + if err := os.WriteFile(filepath.Join(f.stubDir, name), []byte(body), 0o755); err != nil { + f.t.Fatal(err) + } +} + +// withCosign installs a cosign stub that records its arguments and exits with +// the given code. +func (f *installFixture) withCosign(exitCode int) { + f.t.Helper() + f.writeStub("cosign", fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > %q\nexit %d\n", f.cosignLog, exitCode)) +} + +// addAsset writes a release asset for tag. +func (f *installFixture) addAsset(tag, name string, data []byte) { + f.t.Helper() + dir := filepath.Join(f.releaseDir, tag) + if err := os.MkdirAll(dir, 0o755); err != nil { + f.t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), data, 0o644); err != nil { + f.t.Fatal(err) + } +} + +// addRelease publishes a complete fake release (archive, checksums, bundle) for +// version on goos/goarch and returns the archive name. +func (f *installFixture) addRelease(version, goos, goarch string) string { + f.t.Helper() + tag := "v" + version + var archive []byte + var name string + if goos == "windows" { + name = fmt.Sprintf("rho_%s_%s_%s.zip", version, goos, goarch) + archive = makeZip(f.t, "rho.exe", "fake rho "+version) + } else { + name = fmt.Sprintf("rho_%s_%s_%s.tar.gz", version, goos, goarch) + archive = makeTarGz(f.t, "rho", "#!/bin/sh\necho fake rho "+version+"\n") + } + sum := sha256.Sum256(archive) + checksums := fmt.Sprintf("%s %s\n%s rho_%s_source.tar.gz\n", + hex.EncodeToString(sum[:]), name, strings.Repeat("0", 64), version) + f.addAsset(tag, name, archive) + f.addAsset(tag, "checksums.txt", []byte(checksums)) + f.addAsset(tag, "checksums.txt.sigstore.json", []byte(`{"mediaType":"application/vnd.dev.sigstore.bundle.v0.3+json"}`)) + return name +} + +func (f *installFixture) setLatest(tag string) { + f.t.Helper() + body := fmt.Sprintf("{\n \"url\": \"https://api.github.com/repos/GrayCodeAI/rho/releases/1\",\n \"tag_name\": %q,\n \"name\": %q\n}\n", tag, tag) + if err := os.WriteFile(filepath.Join(f.releaseDir, "latest.json"), []byte(body), 0o644); err != nil { + f.t.Fatal(err) + } +} + +func (f *installFixture) run(args ...string) installResult { + f.t.Helper() + cmdArgs := append([]string{filepath.Join(f.root, "install.sh")}, args...) + cmd := exec.Command(filepath.Join(f.toolDir, "sh"), cmdArgs...) + env := []string{ + "PATH=" + f.stubDir + string(os.PathListSeparator) + f.toolDir, + "HOME=" + filepath.Join(filepath.Dir(f.prefix), "home"), + "FAKE_RELEASE_DIR=" + f.releaseDir, + "FAKE_CURL_LOG=" + f.curlLog, + } + for k, v := range f.env { + env = append(env, k+"="+v) + } + cmd.Env = env + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + res := installResult{stdout: stdout.String(), stderr: stderr.String()} + if err != nil { + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + f.t.Fatalf("running install.sh: %v", err) + } + res.exitCode = exitErr.ExitCode() + } + return res +} + +func (f *installFixture) requireInstalled(res installResult, prefix, version string) { + f.t.Helper() + if res.exitCode != 0 { + f.t.Fatalf("install.sh exit %d, want 0\n%s", res.exitCode, res.output()) + } + versioned := filepath.Join(prefix, "bin", "rho-"+version) + if _, err := os.Stat(versioned); err != nil { + f.t.Fatalf("versioned binary missing: %v\n%s", err, res.output()) + } + link, err := os.Readlink(filepath.Join(prefix, "bin", "rho")) + if err != nil { + f.t.Fatalf("rho symlink missing: %v", err) + } + if link != "rho-"+version { + f.t.Fatalf("rho symlink points at %q, want %q", link, "rho-"+version) + } +} + +func (f *installFixture) curlCalls() []string { + f.t.Helper() + data, err := os.ReadFile(f.curlLog) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + f.t.Fatal(err) + } + return strings.Fields(string(data)) +} + +func TestInstallScriptWithoutCosignVerifiesChecksumAndWarns(t *testing.T) { + f := newInstallFixture(t) + f.addRelease(installTestVersion, "linux", "amd64") + + res := f.run("--version", installTestVersion, "--prefix", f.prefix) + f.requireInstalled(res, f.prefix, installTestVersion) + if !strings.Contains(res.stdout, "checksum: verified") { + t.Errorf("summary does not report the checksum verification:\n%s", res.stdout) + } + if !strings.Contains(res.stdout, "signature: NOT verified (cosign not installed)") { + t.Errorf("summary does not say the signature was not verified:\n%s", res.stdout) + } + if !strings.Contains(res.stderr, "WARNING: cosign is not installed") { + t.Errorf("missing cosign warning on stderr:\n%s", res.stderr) + } + for _, call := range f.curlCalls() { + if strings.HasSuffix(call, ".sigstore.json") { + t.Errorf("bundle downloaded although cosign is absent: %s", call) + } + } +} + +func TestInstallScriptRequireCosignFailsClosed(t *testing.T) { + f := newInstallFixture(t) + f.addRelease(installTestVersion, "linux", "amd64") + f.env["RHO_REQUIRE_COSIGN"] = "1" + + res := f.run("--version", installTestVersion, "--prefix", f.prefix) + if res.exitCode == 0 { + t.Fatalf("install succeeded without cosign although RHO_REQUIRE_COSIGN=1\n%s", res.output()) + } + if !strings.Contains(res.stderr, "RHO_REQUIRE_COSIGN=1 but cosign is not installed") { + t.Errorf("unexpected error:\n%s", res.stderr) + } + if _, err := os.Stat(filepath.Join(f.prefix, "bin")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("install directory created despite failure (stat err=%v)", err) + } +} + +func TestInstallScriptCosignUsesExactReleaseIdentity(t *testing.T) { + f := newInstallFixture(t) + f.addRelease(installTestVersion, "linux", "amd64") + f.withCosign(0) + + res := f.run("--version", "v"+installTestVersion, "--prefix", f.prefix) + f.requireInstalled(res, f.prefix, installTestVersion) + + data, err := os.ReadFile(f.cosignLog) + if err != nil { + t.Fatalf("cosign was not invoked: %v\n%s", err, res.output()) + } + args := strings.Split(strings.TrimSpace(string(data)), "\n") + want := map[string]string{ + "--certificate-identity": "https://github.com/GrayCodeAI/rho/.github/workflows/release.yml@refs/tags/v" + installTestVersion, + "--certificate-oidc-issuer": "https://token.actions.githubusercontent.com", + } + if len(args) == 0 || args[0] != "verify-blob" { + t.Fatalf("cosign args = %q, want verify-blob first", args) + } + for i, a := range args { + if a == "--certificate-identity-regexp" { + t.Errorf("cosign called with a regexp identity; want the exact --certificate-identity") + } + if v, ok := want[a]; ok { + if i+1 >= len(args) || args[i+1] != v { + t.Errorf("%s = %q, want %q", a, args[min(i+1, len(args)-1)], v) + } + delete(want, a) + } + if a == "--bundle" && (i+1 >= len(args) || !strings.HasSuffix(args[i+1], "/checksums.txt.sigstore.json")) { + t.Errorf("--bundle does not point at the downloaded checksums.txt.sigstore.json: %q", args) + } + } + for flag := range want { + t.Errorf("cosign not called with %s", flag) + } + if !strings.HasSuffix(args[len(args)-1], "/checksums.txt") { + t.Errorf("cosign must verify checksums.txt, last arg = %q", args[len(args)-1]) + } + if !strings.Contains(res.stdout, "signature: verified with cosign") { + t.Errorf("summary does not report the signature verification:\n%s", res.stdout) + } +} + +func TestInstallScriptCosignFailuresAbort(t *testing.T) { + t.Run("missing bundle", func(t *testing.T) { + f := newInstallFixture(t) + f.addRelease(installTestVersion, "linux", "amd64") + if err := os.Remove(filepath.Join(f.releaseDir, "v"+installTestVersion, "checksums.txt.sigstore.json")); err != nil { + t.Fatal(err) + } + f.withCosign(0) + res := f.run("--version", installTestVersion, "--prefix", f.prefix) + if res.exitCode == 0 || !strings.Contains(res.stderr, "no checksums.txt.sigstore.json signature bundle") { + t.Fatalf("want refusal for an unsigned release, got exit %d\n%s", res.exitCode, res.output()) + } + }) + t.Run("invalid signature", func(t *testing.T) { + f := newInstallFixture(t) + f.addRelease(installTestVersion, "linux", "amd64") + f.withCosign(1) + res := f.run("--version", installTestVersion, "--prefix", f.prefix) + if res.exitCode == 0 || !strings.Contains(res.stderr, "cosign could not verify checksums.txt") { + t.Fatalf("want refusal for a bad signature, got exit %d\n%s", res.exitCode, res.output()) + } + if _, err := os.Stat(filepath.Join(f.prefix, "bin", "rho")); !errors.Is(err, os.ErrNotExist) { + t.Errorf("rho installed despite a failed signature check (stat err=%v)", err) + } + }) +} + +func TestInstallScriptChecksumFailuresAbort(t *testing.T) { + t.Run("mismatch", func(t *testing.T) { + f := newInstallFixture(t) + name := f.addRelease(installTestVersion, "linux", "amd64") + f.addAsset("v"+installTestVersion, name, makeTarGz(t, "rho", "tampered")) + res := f.run("--version", installTestVersion, "--prefix", f.prefix) + if res.exitCode == 0 || !strings.Contains(res.stderr, "SHA-256 mismatch") { + t.Fatalf("want checksum mismatch failure, got exit %d\n%s", res.exitCode, res.output()) + } + }) + t.Run("no entry", func(t *testing.T) { + f := newInstallFixture(t) + f.addRelease(installTestVersion, "linux", "amd64") + f.addAsset("v"+installTestVersion, "checksums.txt", []byte(strings.Repeat("a", 64)+" rho_0.3.0_linux_amd64Xtar.gz\n")) + res := f.run("--version", installTestVersion, "--prefix", f.prefix) + if res.exitCode == 0 || !strings.Contains(res.stderr, "no entry for rho_0.3.0_linux_amd64.tar.gz") { + t.Fatalf("want missing-entry failure (no regex wildcard match), got exit %d\n%s", res.exitCode, res.output()) + } + }) +} + +func TestInstallScriptResolvesLatestRelease(t *testing.T) { + f := newInstallFixture(t) + f.addRelease("0.3.1", "darwin", "arm64") + f.setLatest("v0.3.1") + f.env["FAKE_UNAME_S"] = "Darwin" + f.env["FAKE_UNAME_M"] = "arm64" + + res := f.run("--prefix", f.prefix) + f.requireInstalled(res, f.prefix, "0.3.1") + calls := strings.Join(f.curlCalls(), "\n") + if !strings.Contains(calls, "https://github.com/GrayCodeAI/rho/releases/download/v0.3.1/rho_0.3.1_darwin_arm64.tar.gz") { + t.Errorf("unexpected download URLs:\n%s", calls) + } +} + +func TestInstallScriptRefusesPreRenameReleases(t *testing.T) { + f := newInstallFixture(t) + f.setLatest("v0.2.0") + + res := f.run("--prefix", f.prefix) + if res.exitCode == 0 || !strings.Contains(res.stderr, "v0.2.0 predates the rename to rho") { + t.Fatalf("want a clear refusal for the hawk-era v0.2.0, got exit %d\n%s", res.exitCode, res.output()) + } + for _, call := range f.curlCalls() { + if strings.Contains(call, "/releases/download/") { + t.Errorf("downloaded an asset for a refused version: %s", call) + } + } +} + +func TestInstallScriptFlagParsing(t *testing.T) { + cases := []struct { + name string + args func(prefix string) []string + }{ + {"version then prefix", func(p string) []string { return []string{"--version", installTestVersion, "--prefix", p} }}, + {"prefix then version", func(p string) []string { return []string{"--prefix", p, "--version", installTestVersion} }}, + {"equals forms", func(p string) []string { return []string{"--prefix=" + p, "--version=v" + installTestVersion} }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newInstallFixture(t) + f.addRelease(installTestVersion, "linux", "amd64") + res := f.run(tc.args(f.prefix)...) + f.requireInstalled(res, f.prefix, installTestVersion) + }) + } + + t.Run("RHO_HOME and RHO_VERSION", func(t *testing.T) { + f := newInstallFixture(t) + f.addRelease(installTestVersion, "linux", "amd64") + f.env["RHO_HOME"] = f.prefix + f.env["RHO_VERSION"] = "v" + installTestVersion + f.requireInstalled(f.run(), f.prefix, installTestVersion) + }) + + t.Run("unknown flag", func(t *testing.T) { + f := newInstallFixture(t) + res := f.run("--prefx", f.prefix) + if res.exitCode != 2 || !strings.Contains(res.stderr, "unknown argument: --prefx") { + t.Fatalf("want usage error exit 2, got exit %d\n%s", res.exitCode, res.output()) + } + }) + + t.Run("missing value", func(t *testing.T) { + f := newInstallFixture(t) + res := f.run("--version") + if res.exitCode != 2 || !strings.Contains(res.stderr, "--version needs a value") { + t.Fatalf("want usage error exit 2, got exit %d\n%s", res.exitCode, res.output()) + } + }) + + t.Run("help", func(t *testing.T) { + f := newInstallFixture(t) + res := f.run("--help") + if res.exitCode != 0 || !strings.Contains(res.stdout, "Usage: install.sh") { + t.Fatalf("want usage on stdout, got exit %d\n%s", res.exitCode, res.output()) + } + if len(f.curlCalls()) != 0 { + t.Errorf("--help made network calls: %q", f.curlCalls()) + } + }) +} + +func TestInstallScriptRejectsBadInput(t *testing.T) { + cases := []struct { + name string + setup func(f *installFixture) []string + wantErr string + }{ + {"shell metacharacters in version", func(f *installFixture) []string { + return []string{"--version", "0.3.0;touch /tmp/pwned", "--prefix", f.prefix} + }, "invalid version"}, + {"unsupported architecture", func(f *installFixture) []string { + f.env["FAKE_UNAME_M"] = "mips" + return []string{"--version", installTestVersion, "--prefix", f.prefix} + }, "unsupported architecture: mips"}, + {"unsupported os", func(f *installFixture) []string { + f.env["FAKE_UNAME_S"] = "SunOS" + return []string{"--version", installTestVersion, "--prefix", f.prefix} + }, "unsupported operating system: sunos"}, + {"malformed repo override", func(f *installFixture) []string { + f.env["RHO_INSTALL_REPO"] = "evil/../../x y" + return []string{"--version", installTestVersion, "--prefix", f.prefix} + }, "RHO_INSTALL_REPO must look like owner/repo"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newInstallFixture(t) + res := f.run(tc.setup(f)...) + if res.exitCode == 0 || !strings.Contains(res.stderr, tc.wantErr) { + t.Fatalf("want failure containing %q, got exit %d\n%s", tc.wantErr, res.exitCode, res.output()) + } + if len(f.curlCalls()) != 0 { + t.Errorf("made network calls before rejecting input: %q", f.curlCalls()) + } + }) + } +} + +func TestInstallScriptForkIdentityFollowsRepo(t *testing.T) { + f := newInstallFixture(t) + f.env["RHO_INSTALL_REPO"] = "example/rho-fork" + f.addRelease(installTestVersion, "linux", "arm64") + f.env["FAKE_UNAME_M"] = "aarch64" + f.withCosign(0) + + res := f.run("--version", installTestVersion, "--prefix", f.prefix) + f.requireInstalled(res, f.prefix, installTestVersion) + data, err := os.ReadFile(f.cosignLog) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "https://github.com/example/rho-fork/.github/workflows/release.yml@refs/tags/v0.3.0\n") { + t.Errorf("cosign identity does not follow RHO_INSTALL_REPO:\n%s", data) + } + if !strings.Contains(strings.Join(f.curlCalls(), "\n"), "https://github.com/example/rho-fork/releases/download/v0.3.0/") { + t.Errorf("downloads did not use the fork: %q", f.curlCalls()) + } +} + +func TestInstallScriptWindowsArchive(t *testing.T) { + if _, err := exec.LookPath("unzip"); err != nil { + t.Skipf("unzip not available on this host: %v", err) + } + f := newInstallFixture(t) + f.addRelease(installTestVersion, "windows", "arm64") + f.env["FAKE_UNAME_S"] = "MINGW64_NT-10.0-26100" + f.env["FAKE_UNAME_M"] = "arm64" + + res := f.run("--version", installTestVersion, "--prefix", f.prefix) + if res.exitCode != 0 { + t.Fatalf("install.sh exit %d\n%s", res.exitCode, res.output()) + } + for _, name := range []string{"rho-" + installTestVersion + ".exe", "rho.exe"} { + if _, err := os.Stat(filepath.Join(f.prefix, "bin", name)); err != nil { + t.Errorf("%s not installed: %v", name, err) + } + } +} + +// fakeCurl serves https://github.com//releases/download// +// from $FAKE_RELEASE_DIR// and the releases/latest API from +// $FAKE_RELEASE_DIR/latest.json, exiting 22 (curl -f) for anything missing. +const fakeCurl = `#!/bin/sh +out="" +url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out=$2; shift 2 ;; + https://*) url=$1; shift ;; + *) shift ;; + esac +done +printf '%s\n' "$url" >> "$FAKE_CURL_LOG" +case "$url" in + https://api.github.com/repos/*/releases/latest) src="$FAKE_RELEASE_DIR/latest.json" ;; + https://github.com/*/releases/download/*) src="$FAKE_RELEASE_DIR/${url#*/releases/download/}" ;; + *) echo "fake curl: unexpected URL $url" >&2; exit 3 ;; +esac +[ -f "$src" ] || { echo "curl: (22) The requested URL returned error: 404" >&2; exit 22; } +if [ -n "$out" ]; then cp "$src" "$out"; else cat "$src"; fi +` + +const fakeUname = `#!/bin/sh +case "$1" in + -s) printf '%s\n' "$FAKE_UNAME_S" ;; + -m) printf '%s\n' "$FAKE_UNAME_M" ;; + *) printf '%s\n' "$FAKE_UNAME_S" ;; +esac +` + +func makeTarGz(t *testing.T, name, content string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + files := []struct { + name, body string + mode int64 + }{{name, content, 0o755}, {"README.md", "readme\n", 0o644}} + for _, file := range files { + if err := tw.WriteHeader(&tar.Header{Name: file.name, Mode: file.mode, Size: int64(len(file.body))}); err != nil { + t.Fatal(err) + } + if _, err := tw.Write([]byte(file.body)); err != nil { + t.Fatal(err) + } + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func makeZip(t *testing.T, name, content string) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write([]byte(content)); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} From de0832da2533b3de4f9ebdf8dc0355db0b736202 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:47:51 +0530 Subject: [PATCH 06/16] fix(action): install rho from verified release archives The composite action downloaded the GitHub "latest" release's source tarball with `curl -sL | tar` (no -f, no checksum, no signature) and ran `go build ./cmd/rho` on it. Today that tarball is the hawk-era v0.2.0 tree, which has no cmd/rho, so the action failed for every default invocation; for pinned versions it prepended a second "v" to inputs documented as "v1.2.3"; and a `@main` pin tried to download tag "vmain". The install step now: - installs cosign (sigstore/cosign-installer, pinned SHA) and runs the repository's install.sh at the action's own ref with RHO_REQUIRE_COSIGN=1, so the rho___ archive is checked against the signed checksums.txt (exact release-workflow identity) and its SHA-256 before anything runs; Go is no longer required; - resolves the version as: rho-version input (0.3.0, v0.3.0 or latest), else the release tag the action is pinned to, else the latest release via `gh release view` with the github-token input; - validates rho-repo (owner/repo) and the version before use. Releases before v0.3.0 are refused with a clear message by install.sh. Also drops the stale feat/ecosystem-wiring-pr branch mapping in ci.yml. Checked locally by extracting the step: a @main pin resolves latest (v0.2.0) and is refused as pre-rename, rho-version 0.3.0 fails with a clear 404 until that release exists, and malformed rho-version/rho-repo exit 2. Refs: F031, F254 Co-Authored-By: Claude Opus 5.5 --- .github/actions/rho/action.yml | 73 ++++++++++++++++++---------------- .github/workflows/ci.yml | 2 +- 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/.github/actions/rho/action.yml b/.github/actions/rho/action.yml index fbc14296f..c43ebff01 100644 --- a/.github/actions/rho/action.yml +++ b/.github/actions/rho/action.yml @@ -93,8 +93,8 @@ inputs: default: "" github-token: description: >- - Token used to post a PR comment when post-to is pr-comment. - Defaults to ${{ github.token }}. + Token used to look up the latest Rho release and, when post-to is + pr-comment, to post the PR comment. Defaults to ${{ github.token }}. required: false default: ${{ github.token }} working-directory: @@ -103,12 +103,16 @@ inputs: default: ${{ github.workspace }} rho-version: description: >- - Rho release version/tag to install (e.g. v1.2.3 or latest). - Defaults to the ref this action was resolved at. + Rho release to install (e.g. 0.3.0 or v0.3.0), or latest. Defaults to + the release tag this action is pinned to (uses: ...@v0.3.0); a branch or + commit-SHA pin installs the latest release. Requires Rho v0.3.0 or later: + earlier tags predate the rename and have no rho_* archives. required: false default: "" rho-repo: - description: Repository to install Rho from (owner/repo). + description: >- + Repository to install Rho from (owner/repo). The release archive's + cosign signature must come from that repository's release workflow. required: false default: GrayCodeAI/rho @@ -126,6 +130,11 @@ outputs: runs: using: composite steps: + # cosign verifies the release signature; install.sh runs with + # RHO_REQUIRE_COSIGN=1 so an unsigned or tampered release fails the step. + - name: Install cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Install Rho id: install shell: bash @@ -134,6 +143,7 @@ runs: RHO_VERSION_INPUT: ${{ inputs.rho-version }} ACTION_REF: ${{ github.action_ref }} ACTION_PATH: ${{ github.action_path }} + GH_TOKEN: ${{ inputs.github-token }} RUNNER_TEMP: ${{ runner.temp }} run: | set -euo pipefail @@ -141,40 +151,35 @@ runs: echo "::error::The Rho action currently supports Linux and macOS runners only." >&2 exit 1 fi - # Resolve the version to install: explicit input wins; otherwise the ref - # this action was checked out at (a tag); otherwise the latest release. + if ! [[ "${RHO_REPO}" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "::error::rho-repo must look like owner/repo." >&2 + exit 2 + fi + release_re='^v?[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + # Resolve the version: explicit input wins; otherwise the release tag + # this action is pinned to; otherwise (branch or SHA pin) the latest + # release of rho-repo. version="${RHO_VERSION_INPUT}" - if [ -z "${version}" ]; then + if [ -z "${version}" ] && [[ "${ACTION_REF}" =~ ${release_re} ]]; then version="${ACTION_REF}" fi - # A commit-SHA pin (github.action_ref is the 40-hex SHA) is not a release - # tag, so fall back to latest rather than fetching releases/download/v. - case "${version}" in - ""|refs/*|*/*) version="latest" ;; - esac - if printf '%s' "${version}" | grep -qiE '^[0-9a-f]{40}$'; then - version="latest" + if [ -z "${version}" ] || [ "${version}" = "latest" ]; then + version="$(gh release view --repo "${RHO_REPO}" --json tagName --jq .tagName)" fi - install_dir="${RUNNER_TEMP}/rho-bin" - src_dir="${RUNNER_TEMP}/rho-src" - mkdir -p "${install_dir}" "${src_dir}" - # Download the Rho source archive and extract it - if [ "${version}" = "latest" ]; then - tarball_url=$(curl -sL "https://api.github.com/repos/${RHO_REPO}/releases/latest" \ - | grep '"tarball_url":' | sed 's/.*"\([^"]*\)".*/\1/') - else - tarball_url="https://github.com/${RHO_REPO}/archive/refs/tags/v${version}.tar.gz" - fi - curl -sL "${tarball_url}" | tar -xz -C "${src_dir}" --strip-components=1 - # Build the Rho binary from source (Go must be available on the runner) - if ! command -v go &>/dev/null; then - echo "::error::Go is required but not found on the runner PATH. Add actions/setup-go before this action, or install Go manually." >&2 - exit 1 + if ! [[ "${version}" =~ ${release_re} ]]; then + echo "::error::rho-version must be a release version such as 0.3.0, or 'latest'." >&2 + exit 2 fi - go build -o "${install_dir}/rho" "${src_dir}/cmd/rho" - chmod +x "${install_dir}/rho" - echo "${install_dir}" >> "${GITHUB_PATH}" - echo "Installed Rho from ${RHO_REPO}@${version}" + version="${version#v}" + # install.sh ships with this action at the same ref. It downloads the + # release archive and checksums.txt, verifies the cosign signature + # (required here) and the SHA-256, and installs under the prefix. + installer="$(cd "${ACTION_PATH}/../../.." && pwd)/install.sh" + prefix="${RUNNER_TEMP}/rho" + RHO_INSTALL_REPO="${RHO_REPO}" RHO_REQUIRE_COSIGN=1 \ + sh "${installer}" --version "${version}" --prefix "${prefix}" + echo "${prefix}/bin" >> "${GITHUB_PATH}" + echo "Installed Rho ${version} from ${RHO_REPO}" - name: Run Rho id: run diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40cce2441..21824dc30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,7 +96,7 @@ jobs: fetch-depth: 0 - uses: ./.github/actions/checkout-flux with: - ref: ${{ github.head_ref == 'feat/ecosystem-wiring-pr' && 'feat/ecosystem-wiring' || github.head_ref || github.ref_name }} + ref: ${{ github.head_ref || github.ref_name }} - name: Generate ecosystem workspace run: bash ./scripts/generate-workspace.sh - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 From a2640deafbfc7ad45a41830b7b2f21ef6f0ad36a Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:49:34 +0530 Subject: [PATCH 07/16] docs(readme): make the install section truthful Every documented install path failed for a new user: - `curl .../main/install.sh | sh` requested rho_0.2.0_* assets from the latest release, which only has hawk_* assets (404); - `brew install graycodeai/tap/rho`: GrayCodeAI/homebrew-tap does not exist; - `npm install -g @graycodeai/rho`: nothing is published (E404); - `go install .../cmd/rho@latest`: @latest is the hawk-era v0.2.0, which has no cmd/rho ("does not contain package"). The Install section now says prebuilt installs are available from v0.3.0 (the first rho-named release) and points to the source build until then; shows the script from the immutable v0.3.0 tag (tags are protected) with what it verifies and RHO_REQUIRE_COSIGN=1; lists the release assets; and states plainly that Homebrew and npm packages do not exist. The contributor `go install` line uses @main, verified today with a clean GOBIN against proxy.golang.org, and explains why @latest fails until v0.3.0 is tagged. Refs: F002, F019, F025, F262 Co-Authored-By: Claude Opus 5.5 --- README.md | 38 ++++++++++++++++++++++++++++---------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d36c692fe..6dfc24946 100644 --- a/README.md +++ b/README.md @@ -43,21 +43,37 @@ rho is an AI-powered coding agent that lives in your terminal. It reads your cod Follow [GrayCode](https://github.com/GrayCodeAI) for progress. When Rho is ready to try, we will announce it on [graycodeai.com](https://graycodeai.com/changelog). -## Install (60 seconds) +## Install -Pick one — all install the same `rho` binary (versioned into `~/.rho/bin`, symlinked as `rho`): +Prebuilt installs are **available from v0.3.0**, the first release published +under the `rho` name. Until v0.3.0 appears on the +[releases page](https://github.com/GrayCodeAI/rho/releases), build from source +(see [Quick Start](#quick-start-contributors--from-source)). The older +`v0.1.0`–`v0.2.0` tags predate the rename: they ship `hawk_*` archives under +the old module path and cannot be installed as `rho`. + +From v0.3.0: ```bash -# 1. Script (any shell, verifies checksum; cosign signature when available) -curl -fsSL https://raw.githubusercontent.com/GrayCodeAI/rho/main/install.sh | sh +# Script: installs ~/.rho/bin/rho- and links it as ~/.rho/bin/rho. +# Always checks the archive's SHA-256 against checksums.txt; also verifies the +# cosign signature of checksums.txt when cosign is installed (it says so when +# it is not). Set RHO_REQUIRE_COSIGN=1 to refuse installing without cosign. +curl -fsSL https://raw.githubusercontent.com/GrayCodeAI/rho/v0.3.0/install.sh | sh -# 2. Homebrew (macOS / Linuxbrew) — after the next tagged release -brew install graycodeai/tap/rho +# Pin a version or choose the install prefix (see: sh install.sh --help) +curl -fsSL https://raw.githubusercontent.com/GrayCodeAI/rho/v0.3.0/install.sh | sh -s -- --version 0.3.0 --prefix "$HOME/.rho" -# 3. npm (wraps the same release binaries) -npm install -g @graycodeai/rho +# Go toolchain (Go 1.26+): builds the tagged module from source +go install github.com/GrayCodeAI/rho/cmd/rho@latest ``` +Each release carries `rho___` archives for +linux/darwin/windows on amd64/arm64, `checksums.txt`, its cosign signature +bundle `checksums.txt.sigstore.json`, and SPDX SBOMs; the release notes show how +to verify them by hand. Homebrew and npm packages are **not available**: there +is no `graycodeai/tap` tap and nothing is published under `@graycodeai` on npm. + If `~/.rho/bin` is not on your `PATH`, add it to your shell profile. Then: @@ -87,10 +103,12 @@ checklist: credentials → model → catalog → ecosystem. See [docs/SECURITY-DEVELOPER.md](docs/SECURITY-DEVELOPER.md) for the credential model. Do not put API keys in shell env or `.env` for rho. -Optional for contributors: +Optional: install the current `main` with the Go toolchain. Use `@main` until +v0.3.0 is tagged: `@latest` still resolves to the hawk-era v0.2.0, which does not +contain `cmd/rho`. ```bash -go install github.com/GrayCodeAI/rho/cmd/rho@latest +go install github.com/GrayCodeAI/rho/cmd/rho@main ``` ## Features From a5cd3fbe419f5a21522ba5a5ec874f30cde8bec3 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:53:55 +0530 Subject: [PATCH 08/16] build(npm): gate the unpublished npm packaging and fix its assembly `npm install -g @graycodeai/rho` is E404: nothing is published, there is no publish job, and the owner decided not to create the npm org now. The scaffolding also had defects that would have broken a publish: - every package.json said "license": "Apache-2.0" while the project is MIT (LICENSE, goreleaser); - the assemble script stamped only the sub-packages, leaving the meta package and its optionalDependencies at 0.0.0-development, so every install would report "platform package not installed"; - it looked for dist/rho__arm64/ but GoReleaser v2 writes dist/rho__arm64_v8.0/, so the arm64 targets always failed. Changes: license MIT everywhere; "private": true on all seven packages so `npm publish` refuses until a real channel exists; the assemble script takes the version from VERSION (or RHO_NPM_VERSION), validates it, stamps the meta package and its optionalDependencies, and uses the real GoReleaser v2 directory names. npm/README.md states the channel is not available and lists what a publish needs. The README no longer advertises npm. Checked on a scratch copy against the local snapshot dist/: all six targets assembled at 0.3.0 and the meta package pins 0.3.0. Refs: F020 Co-Authored-By: Claude Opus 5.5 --- npm/README.md | 37 +++++++++++ npm/rho-darwin-arm64/package.json | 3 +- npm/rho-darwin-x64/package.json | 3 +- npm/rho-linux-arm64/package.json | 3 +- npm/rho-linux-x64/package.json | 3 +- npm/rho-win32-arm64/package.json | 3 +- npm/rho-win32-x64/package.json | 3 +- npm/rho/package.json | 3 +- npm/rho/scripts/assemble-platform-packages.js | 62 +++++++++++++------ 9 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 npm/README.md diff --git a/npm/README.md b/npm/README.md new file mode 100644 index 000000000..f335ea119 --- /dev/null +++ b/npm/README.md @@ -0,0 +1,37 @@ +# npm packaging (not published) + +**Rho is not available on npm.** Nothing is published under the `@graycodeai` +scope, no workflow publishes these packages, and every `package.json` in this +directory sets `"private": true` so `npm publish` refuses to run. Install Rho +as described in the [README](../README.md#install). + +This directory is scaffolding for a future npm channel: + +- `rho/` — the meta package `@graycodeai/rho`. Its `postinstall.js` copies the + binary from the matching platform package into `~/.rho/bin` using the same + versioned-file-plus-symlink layout as `install.sh`. +- `rho--/` — one package per target (darwin, linux, win32 × + x64, arm64), each holding a brotli-compressed binary. +- `rho/scripts/assemble-platform-packages.js` — fills the platform packages + from GoReleaser's `dist/` output and stamps every package, including the meta + package's `optionalDependencies`, with the release version (the repository + `VERSION` file, or `RHO_NPM_VERSION`). + +To try the assembly locally without publishing: + +```bash +goreleaser release --snapshot --clean --skip=publish,sign +cp -R npm VERSION "$(mktemp -d)"/ # work on a copy; assembly rewrites package.json files +# in the copy: cd npm/rho && DIST_ROOT=/dist node scripts/assemble-platform-packages.js +``` + +Before this channel can be offered, all of the following are needed: + +1. An npm organization `graycodeai` owned by GrayCode, with npm trusted + publishing (GitHub OIDC) configured for this repository's release workflow. +2. A release job that runs after GoReleaser: assemble the packages, publish the + six platform packages, then the meta package, with `--provenance`. +3. A `THIRD_PARTY_NOTICES.md` (for example generated with `go-licenses`); the + `files` lists reference it and the assemble script copies it when present. +4. Removing `"private": true` from the packages in the same change, and adding + the npm command back to the README install section. diff --git a/npm/rho-darwin-arm64/package.json b/npm/rho-darwin-arm64/package.json index 9308ef583..21ea5cc2a 100644 --- a/npm/rho-darwin-arm64/package.json +++ b/npm/rho-darwin-arm64/package.json @@ -1,6 +1,7 @@ { "version": "0.0.0-development", - "license": "Apache-2.0", + "private": true, + "license": "MIT", "files": [ "bin/", "THIRD_PARTY_NOTICES.md" diff --git a/npm/rho-darwin-x64/package.json b/npm/rho-darwin-x64/package.json index 99fc70c4e..6b7a44a64 100644 --- a/npm/rho-darwin-x64/package.json +++ b/npm/rho-darwin-x64/package.json @@ -1,6 +1,7 @@ { "version": "0.0.0-development", - "license": "Apache-2.0", + "private": true, + "license": "MIT", "files": [ "bin/", "THIRD_PARTY_NOTICES.md" diff --git a/npm/rho-linux-arm64/package.json b/npm/rho-linux-arm64/package.json index 61b9c1770..b0fa7577c 100644 --- a/npm/rho-linux-arm64/package.json +++ b/npm/rho-linux-arm64/package.json @@ -1,6 +1,7 @@ { "version": "0.0.0-development", - "license": "Apache-2.0", + "private": true, + "license": "MIT", "files": [ "bin/", "THIRD_PARTY_NOTICES.md" diff --git a/npm/rho-linux-x64/package.json b/npm/rho-linux-x64/package.json index 373eb732a..ed63177f4 100644 --- a/npm/rho-linux-x64/package.json +++ b/npm/rho-linux-x64/package.json @@ -1,6 +1,7 @@ { "version": "0.0.0-development", - "license": "Apache-2.0", + "private": true, + "license": "MIT", "files": [ "bin/", "THIRD_PARTY_NOTICES.md" diff --git a/npm/rho-win32-arm64/package.json b/npm/rho-win32-arm64/package.json index c819840f5..a1f57e28a 100644 --- a/npm/rho-win32-arm64/package.json +++ b/npm/rho-win32-arm64/package.json @@ -1,6 +1,7 @@ { "version": "0.0.0-development", - "license": "Apache-2.0", + "private": true, + "license": "MIT", "files": [ "bin/", "THIRD_PARTY_NOTICES.md" diff --git a/npm/rho-win32-x64/package.json b/npm/rho-win32-x64/package.json index edff50cb2..82be2d6ad 100644 --- a/npm/rho-win32-x64/package.json +++ b/npm/rho-win32-x64/package.json @@ -1,6 +1,7 @@ { "version": "0.0.0-development", - "license": "Apache-2.0", + "private": true, + "license": "MIT", "files": [ "bin/", "THIRD_PARTY_NOTICES.md" diff --git a/npm/rho/package.json b/npm/rho/package.json index 062792182..89a5ea8f0 100644 --- a/npm/rho/package.json +++ b/npm/rho/package.json @@ -1,8 +1,9 @@ { "name": "@graycodeai/rho", "version": "0.0.0-development", + "private": true, "description": "Bring Rho into your terminal", - "license": "Apache-2.0", + "license": "MIT", "os": [ "darwin", "linux", diff --git a/npm/rho/scripts/assemble-platform-packages.js b/npm/rho/scripts/assemble-platform-packages.js index 2358de77a..ded3f7a51 100644 --- a/npm/rho/scripts/assemble-platform-packages.js +++ b/npm/rho/scripts/assemble-platform-packages.js @@ -1,26 +1,29 @@ #!/usr/bin/env node // Assemble the six per-platform npm packages prior to `npm publish`. // +// STATUS: nothing is published to npm. Every package.json here is marked +// "private": true so `npm publish` refuses, and no workflow publishes them. +// See npm/README.md for what a real publish needs. +// // For each supported (platform, arch) target this: // 1. Brotli-compresses the built Rho binary into // `../rho--/bin/.br` -// 2. Stamps the sub-package's version to match the meta package +// 2. Stamps the sub-package's version with the release version // 3. Copies the third-party notices file into the sub-package +// and finally stamps the meta package (`@graycodeai/rho`) version and its +// `optionalDependencies` pins with the same version, so npm resolves the +// matching platform package. // -// Each per-platform package is its own npm publish target. The meta package -// (`@graycodeai/rho`) lists all six as `optionalDependencies` pinned to -// the same version; npm installs only the one matching the host's -// `os` + `cpu` filters. +// The release version is RHO_NPM_VERSION when set, otherwise the repository's +// VERSION file (which release.yml requires to match the tag). // -// Why brotli? npm's tarball ceiling is ~200 MB and the raw Go binary is -// 100–150 MB per platform. Brotli at max quality cuts that to 30–40 MB, -// leaves plenty of headroom for binary growth, and is decoded by Node's +// Why brotli? npm's tarball ceiling is ~200 MB. Brotli at max quality shrinks +// the ~60 MB stripped Go binary substantially and is decoded by Node's // built-in zlib.brotliDecompressSync (no native deps required). // // Source paths come from environment variables (set in CI) and fall back to -// the default GoReleaser build output dirs for local testing. +// the GoReleaser v2 build output dirs for local testing. // -// Source pattern: grok `npm/grok/scripts/assemble-platform-packages.js`. // Rho's binary is the plain Go binary named `rho` (or `rho.exe`), produced // by GoReleaser (`.goreleaser.yml`, main `./cmd/rho`, project_name `rho`). const fs = require('fs'); @@ -42,8 +45,18 @@ const NOTICES_SOURCE = process.env.RHO_THIRD_PARTY_NOTICES const NOTICES_NAME = 'THIRD_PARTY_NOTICES.md'; const META_PKG_JSON = path.resolve(__dirname, '..', 'package.json'); -const meta = JSON.parse(fs.readFileSync(META_PKG_JSON, 'utf8')); -const VERSION = meta.version; +const REPO_VERSION_FILE = path.resolve(npmRoot, '..', 'VERSION'); + +function releaseVersion() { + const raw = process.env.RHO_NPM_VERSION || fs.readFileSync(REPO_VERSION_FILE, 'utf8'); + const version = raw.trim().replace(/^v/, ''); + if (!/^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(version)) { + throw new Error(`invalid release version ${JSON.stringify(raw.trim())} (want MAJOR.MINOR.PATCH)`); + } + return version; +} + +const VERSION = releaseVersion(); function ensureDir(p) { fs.mkdirSync(path.dirname(p), { recursive: true }); } @@ -91,12 +104,12 @@ async function packPlatform({ platform, arch, envVar, defaultSource, binName }) } async function main() { - // Default source paths follow GoReleaser v2 layout: - // dist/__[vN]/binary - // with binary `rho` (Unix) / `rho.exe` (Windows). The build ID is `rho`, - // so the dir is `dist/rho___v1`. The `[vN]` arch version suffix - // is GoReleaser's GOAMD64 default (v1 for amd64); if your config changes - // this, set the RHO_* env vars instead of relying on the defaults. + // Default source paths follow the GoReleaser v2 layout + // dist/___/ + // with build id `rho`, binary `rho` (Unix) / `rho.exe` (Windows), and the + // default arch levels GOAMD64=v1 and GOARM64=v8.0 — e.g. + // dist/rho_linux_amd64_v1/rho and dist/rho_darwin_arm64_v8.0/rho. If the + // config changes these, set the RHO_* env vars instead. const goosGoarch = { darwin: { arm64: 'arm64', x64: 'amd64' }, linux: { arm64: 'arm64', x64: 'amd64' }, @@ -108,7 +121,7 @@ async function main() { if (!ga) throw new Error(`no goarch mapping for ${platform}-${arch}`); const os = platform === 'win32' ? 'windows' : platform; const bin = platform === 'win32' ? 'rho.exe' : 'rho'; - const dirName = `rho_${os}_${ga}${ga === 'arm64' ? '' : '_v1'}`; + const dirName = `rho_${os}_${ga}_${ga === 'arm64' ? 'v8.0' : 'v1'}`; return path.join(distRoot, dirName, bin); } @@ -157,7 +170,16 @@ async function main() { process.exit(1); } - console.log(`[assemble] All 6 per-platform packages assembled at version ${VERSION}.`); + // Pin the meta package and its optionalDependencies to the same version; + // otherwise npm would resolve the sub-packages at 0.0.0-development. + const meta = JSON.parse(fs.readFileSync(META_PKG_JSON, 'utf8')); + meta.version = VERSION; + for (const name of Object.keys(meta.optionalDependencies || {})) { + meta.optionalDependencies[name] = VERSION; + } + fs.writeFileSync(META_PKG_JSON, JSON.stringify(meta, null, 4) + '\n'); + + console.log(`[assemble] All ${targets.length} per-platform packages and the meta package assembled at version ${VERSION}.`); } main().catch((err) => { console.error(err); process.exit(1); }); From 9acd613717edefb2c400ae6010b51d0e0e12e8d9 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:54:22 +0530 Subject: [PATCH 09/16] build(nix): remove the flake that cannot evaluate flake.nix declared inputs for five GitHub repositories that no longer exist (merlin, kestrel, shrike, swift, harrier), so `nix build` and `nix develop` failed while fetching inputs. Even with those removed it would have built with vendorHash = null and GONOSUMCHECK=1 (no dependency integrity), hardcoded version 0.0.1, and no flake.lock. Nothing in CI evaluated it and the README does not offer Nix. Nix is not installed where this change was prepared and CI never ran it, so a fixed flake (real vendorHash, flake.lock, `nix flake check` in CI) could not be verified. Rather than ship another unverified install path, remove flake.nix and the `use flake` .envrc that only loaded it; /.envrc is now gitignored so a local direnv file (which `make test-live` may read keys from) is never committed. Re-adding a verified flake is recorded as a follow-up in docs/RELEASING.md. Refs: F027, F211, F263 Co-Authored-By: Claude Opus 5.5 --- .envrc | 1 - .gitignore | 6 ++- flake.nix | 132 ----------------------------------------------------- 3 files changed, 4 insertions(+), 135 deletions(-) delete mode 100644 .envrc delete mode 100644 flake.nix diff --git a/.envrc b/.envrc deleted file mode 100644 index 3550a30f2..000000000 --- a/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/.gitignore b/.gitignore index 457aa9f92..66377da47 100644 --- a/.gitignore +++ b/.gitignore @@ -40,10 +40,12 @@ coverage.html CODE_REVIEW_REPORT.md review_report.md -# Workspace checksum file — regenerated by `make setup` / `go work sync`; -# deleted and regenerated by the Dockerfile and flake.nix builds. +# Workspace checksum file — regenerated by `make setup` / `go work sync`. go.work.sum +# Local direnv file (may hold personal keys for `make test-live`). +/.envrc + # Lefthook-generated git hook wrappers .githooks/ __pycache__/ diff --git a/flake.nix b/flake.nix deleted file mode 100644 index 517bd16f3..000000000 --- a/flake.nix +++ /dev/null @@ -1,132 +0,0 @@ -{ - description = "Rho - AI coding agent powered by flux"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - # GrayCodeAI sibling repos — the public Go proxy has stale v0.1.0 tags - # (post-history-rewrite), so resolve them locally in the Nix build. - flux = { url = "github:GrayCodeAI/flux"; flake = false; }; - merlin = { url = "github:GrayCodeAI/merlin"; flake = false; }; - kestrel = { url = "github:GrayCodeAI/kestrel"; flake = false; }; - shrike = { url = "github:GrayCodeAI/shrike"; flake = false; }; - swift = { url = "github:GrayCodeAI/swift"; flake = false; }; - harrier = { url = "github:GrayCodeAI/harrier"; flake = false; }; - }; - - outputs = { self, nixpkgs, flake-utils, flux, merlin, kestrel, shrike, swift, harrier }: - flake-utils.lib.eachDefaultSystem (system: - let - pkgs = nixpkgs.legacyPackages.${system}; - inherit (pkgs) lib; - - siblings = { - "github.com/GrayCodeAI/flux" = flux; - "github.com/GrayCodeAI/merlin" = merlin; - "github.com/GrayCodeAI/kestrel" = kestrel; - "github.com/GrayCodeAI/shrike" = shrike; - "github.com/GrayCodeAI/swift" = swift; - "github.com/GrayCodeAI/harrier" = harrier; - }; - - # GrayCode modules currently follow github.com//, so the - # last path segment matches the sibling checkout directory name. - dirOf = mod: lib.last (lib.splitString "/" mod); - goVer = lib.removePrefix "go" "${pkgs.go_1_26.version}"; - - # Copy sibling sources into a private Nix build staging directory, - # patch the go.mod go directive - # to match the nixpkgs Go version, and add replace directives so the - # build resolves siblings locally instead of hitting the stale proxy. - setupReplace = '' - sed -i 's/^go [0-9.]\+/go ${goVer}/' go.mod - rm -f go.work go.work.sum - mkdir -p deps - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (mod: src: - "cp -r ${src} deps/${dirOf mod}" - ) siblings)} - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (mod: _: - "tmp_go_mod=$(mktemp) && awk '$0 != \"replace ${mod} => ./deps/${dirOf mod}\"' go.mod > \"$tmp_go_mod\" && mv \"$tmp_go_mod\" go.mod" - ) siblings)} - ${lib.concatStringsSep "\n" (lib.mapAttrsToList (mod: _: - "echo \"replace ${mod} => ./deps/${dirOf mod}\" >> go.mod" - ) siblings)} - ''; - - rho = pkgs.buildGoModule rec { - pname = "rho"; - version = "0.0.1"; - - src = ./.; - - # The public Go proxy has stale v0.1.0 tags for GrayCodeAI sibling - # modules (post-history-rewrite). We resolve sibling sources locally - # via replace directives in go.mod (added in preBuild below). Other - # deps (charmbracelet, cobra, …) are fetched from the proxy. The - # vendor FOD is skipped (null) because the proxy version mismatch - # prevents `go mod vendor` from passing checksum verification. - # TODO(supply-chain): vendorHash = null + GONOSUMCHECK disables - # dependency integrity/reproducibility for this build. Restore a - # fixed-output vendorHash ("sha256-…", computed via `nix build` - # after setupReplace) so external deps are verified, and drop - # GONOSUMCHECK for non-GrayCodeAI modules. - vendorHash = null; - - env = { - GOPRIVATE = "github.com/GrayCodeAI/*"; - GONOSUMDB = "github.com/GrayCodeAI/*"; - GONOSUMCHECK = "1"; - GOFLAGS = "-mod=mod"; - }; - - # Add replace directives to go.mod so the build and vendor FOD - # resolve sibling modules locally instead of from the stale proxy. - preBuild = setupReplace; - overrideModAttrs = old: { - preBuild = setupReplace; - }; - - ldflags = [ - "-s" - "-w" - "-X main.Version=${version}" - ]; - - nativeBuildInputs = [ pkgs.git ]; - - meta = with lib; { - description = "AI coding agent that reads, writes, and runs code in your terminal"; - homepage = "https://github.com/GrayCodeAI/rho"; - license = licenses.mit; - maintainers = [ ]; - }; - }; - in - { - packages = { - default = rho; - inherit rho; - }; - - devShells.default = pkgs.mkShell { - buildInputs = with pkgs; [ - go_1_26 - gopls - gotools - go-tools - golangci-lint - git - ]; - - shellHook = '' - echo "Rho development shell" - echo "Go version: $(go version)" - ''; - }; - - apps.default = { - type = "app"; - program = "${rho}/bin/rho"; - }; - }); -} From ee18c530ae343bfc7096df23e7b8f1675e062f68 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:55:05 +0530 Subject: [PATCH 10/16] ci: fail the build when the release binary exceeds its size budget The CI "size check" only emitted a ::warning above 98 MB and never failed; `make size-check` warned at a different threshold (80 MB, "matching CI"), and both comments claimed the binary was ~95 MB. The stripped linux/amd64 build of ./cmd/rho is 62,492,834 bytes (59.6 MiB) today, so any regression short of +38 MB passed silently. The budget now lives in one place, SIZE_LIMIT_MB := 70 in the Makefile (~10 MiB headroom), `make size-check` exits 1 above it (and no longer needs bc or platform-specific stat flags), and the CI build job runs `make size-check` on linux/amd64 instead of its own copy of the logic. Verified: `make size-check` passes at 60 MiB and fails with SIZE_LIMIT_MB=50. Refs: F030 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 23 ++++------------------- Makefile | 19 ++++++++++++++----- 2 files changed, 18 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21824dc30..9c2ca8196 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -504,26 +504,11 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: "0" run: go build -trimpath -v ./... - - name: Binary size check (linux/amd64 only) + - name: Binary size budget (linux/amd64 only) if: matrix.goos == 'linux' && matrix.goarch == 'amd64' - run: | - # Use -ldflags="-s -w" to match release build size (Makefile LDFLAGS). - # This catches binary bloat regressions against the actual shipped artifact size. - size=$(go build -trimpath -ldflags="-s -w" -o /tmp/rho-bin ./cmd/rho && wc -c < /tmp/rho-bin) - size_mb=$((size / 1024 / 1024)) - echo "Binary size: ${size_mb}MB" - # Threshold history: 110MB → 80MB (binary was ~76MB) → 98MB. - # The 80MB line became stale: the audit-sweep merges (swift - # rebrand, harrier at-rest encryption, engine features) grew the - # release binary to ~95MB (linux/amd64, -trimpath -ldflags="-s -w"). - # Re-baselined to 98MB (3MB headroom) so this check detects new - # bloat regressions instead of warning on every run. A real size - # reduction (e.g. swapping modernc.org/sqlite for cgo sqlite, - # lazy-loading WASM/CEL) is tracked separately. - if [ "$size_mb" -gt 98 ]; then - echo "::warning::Binary size ${size_mb}MB exceeds 98MB threshold" - fi - rm -f /tmp/rho-bin + # Builds bin/rho with the release ldflags (-s -w) and fails when it + # exceeds SIZE_LIMIT_MB in the Makefile — the single source of the budget. + run: make size-check # ------------------------------------------------------------------------- # Fuzz — short corpus runs to catch panics in fuzz targets. diff --git a/Makefile b/Makefile index e2550b11b..9cd05f123 100644 --- a/Makefile +++ b/Makefile @@ -259,8 +259,17 @@ build-static: ## Build fully static binaries for Linux (musl-compatible) GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(NAME)-linux-amd64-static $(MAIN_PKG) GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o bin/$(NAME)-linux-arm64-static $(MAIN_PKG) -size-check: build ## Report binary size and warn if over threshold (80MB, matching CI). - @SIZE=$$(stat -f%z bin/$(NAME) 2>/dev/null || stat -c%s bin/$(NAME) 2>/dev/null); \ - MB=$$(echo "scale=1; $$SIZE / 1048576" | bc); \ - echo "Binary size: $${MB} MB"; \ - if [ $$SIZE -gt 83886080 ]; then echo "::warning::Binary size $${MB} MB exceeds 80 MB threshold (CI gate)"; fi +# Size budget for the stripped release binary (MiB). CI's build job runs +# `make size-check` on linux/amd64, which measured 59.6 MiB (62,492,834 bytes) +# on 2026-09-27; 70 MiB leaves ~10 MiB of headroom. Raise it only in a PR that +# explains the growth. +SIZE_LIMIT_MB := 70 + +size-check: build ## Fail if bin/$(NAME) (release ldflags) exceeds SIZE_LIMIT_MB. + @size=$$(wc -c < bin/$(NAME) | tr -d ' '); \ + limit=$$(( $(SIZE_LIMIT_MB) * 1024 * 1024 )); \ + echo "Binary size: $$size bytes ($$(( size / 1048576 )) MiB; budget $(SIZE_LIMIT_MB) MiB)"; \ + if [ "$$size" -gt "$$limit" ]; then \ + echo "::error::Binary size $$(( size / 1048576 )) MiB exceeds the $(SIZE_LIMIT_MB) MiB budget (Makefile SIZE_LIMIT_MB)"; \ + exit 1; \ + fi From 11ce708fce3d0c48eb84d888a835bc8e11abb5cb Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:56:47 +0530 Subject: [PATCH 11/16] fix(packaging): make the systemd unit's state reachable under its hardening packaging/systemd/rho-daemon.service could not work as written: - ProtectHome=true makes /home, /root and /run/user inaccessible, yet the unit granted ReadWritePaths=%h/.rho/state and ReadOnlyPaths=%h/.rho. In a system unit %h is always /root (systemd.unit(5)), and rho does not keep state in ~/.rho anyway (it uses the user config dir). - The docs said the unit runs as user `rho`, but it had no User=, so it ran as root. - `--api-key ${RHO_DAEMON_API_KEY}` put the key on the command line (visible in ps) although the daemon already reads the variable. - Documentation= pointed at https://docs.rho.ai, which does not resolve. The unit now runs as User=rho with StateDirectory=rho/CacheDirectory=rho and points HOME, RHO_CONFIG_DIR, RHO_STATE_DIR and RHO_CACHE_DIR there, keeps ProtectSystem=strict/ProtectHome=true/PrivateTmp, loads the API key from an optional root-owned EnvironmentFile, and links the rho troubleshooting guide, whose systemd section now documents the user, paths, log file and how to grant project directories. internal/testaudit/packaging_test.go parses the unit and fails on paths hidden by ProtectHome, a missing User=, state outside StateDirectory, an API key on ExecStart, or non-repository documentation links. The unit was not started under systemd here (no systemd/systemd-analyze on this machine); the ProtectHome and %h semantics are from systemd.exec(5) and systemd.unit(5). Refs: F032 Co-Authored-By: Claude Opus 5.5 --- docs/troubleshooting-guide.md | 28 ++++---- internal/testaudit/packaging_test.go | 95 ++++++++++++++++++++++++++++ packaging/systemd/rho-daemon.service | 58 +++++++++++------ 3 files changed, 150 insertions(+), 31 deletions(-) create mode 100644 internal/testaudit/packaging_test.go diff --git a/docs/troubleshooting-guide.md b/docs/troubleshooting-guide.md index 1106e7a22..1b6ed3168 100644 --- a/docs/troubleshooting-guide.md +++ b/docs/troubleshooting-guide.md @@ -336,33 +336,39 @@ large contexts can consume significant memory. Consider: ### "Failed to start rho-daemon.service: Unit not found" -Install the unit file: +Install the unit file, the service user and the binary it runs: ```bash +sudo useradd --system --home-dir /var/lib/rho --shell /usr/sbin/nologin rho +sudo install -m 0755 ~/.rho/bin/rho /usr/local/bin/rho sudo cp packaging/systemd/rho-daemon.service /etc/systemd/system/ +# Optional: sudo install -D -m 0600 /dev/null /etc/rho/daemon.env, then add +# RHO_DAEMON_API_KEY= (the key is read from the environment, not argv). sudo systemctl daemon-reload sudo systemctl enable --now rho-daemon ``` ### "Permission denied" accessing state directory -The systemd unit runs as user `rho` with `ProtectSystem=strict` and -`ReadWritePaths=%h/.rho/state`. Ensure the user's home directory has -the correct state: - -```bash -sudo -u rho mkdir -p /home/rho/.rho/state -sudo -u rho chmod 750 /home/rho/.rho/state -``` +The unit runs as user `rho` with `ProtectSystem=strict` and +`ProtectHome=true`, so the only writable locations are the directories +systemd creates for it (`StateDirectory=rho`, `CacheDirectory=rho`) and a +private `/tmp`. Config lives in `/var/lib/rho/config`, state (sessions, security +log, `run/daemon.log`) in `/var/lib/rho/state`, and cache in `/var/cache/rho`. +Home directories are hidden from the service, so do not point +`ReadWritePaths=` or the `RHO_*_DIR` variables at `/home`, `/root` or `%h` +(`%h` is always `/root` in a system unit). To let the agent edit project +checkouts, add them explicitly, for example `ReadWritePaths=/srv/projects` +in a drop-in (`sudo systemctl edit rho-daemon`). ### Daemon not logging to journald If using the systemd unit, logs go to both journald (stdout/stderr) and -`~/.rho/state/daemon.log`. Check: +`/var/lib/rho/state/run/daemon.log`. Check: ```bash journalctl -u rho-daemon -f -tail -f ~/.rho/state/daemon.log +sudo tail -f /var/lib/rho/state/run/daemon.log ``` If journald logs are missing, verify that stdout/stderr are not being diff --git a/internal/testaudit/packaging_test.go b/internal/testaudit/packaging_test.go new file mode 100644 index 000000000..0492ee720 --- /dev/null +++ b/internal/testaudit/packaging_test.go @@ -0,0 +1,95 @@ +package testaudit + +import ( + "strings" + "testing" +) + +// parseSystemdUnit returns directive -> values for a systemd unit file, +// joining backslash-continued lines and skipping comments. +func parseSystemdUnit(t *testing.T, data string) map[string][]string { + t.Helper() + out := map[string][]string{} + var logical []string + var cur strings.Builder + for _, line := range strings.Split(data, "\n") { + trimmed := strings.TrimSpace(line) + if cur.Len() == 0 && (trimmed == "" || strings.HasPrefix(trimmed, "#") || strings.HasPrefix(trimmed, ";")) { + continue + } + if strings.HasSuffix(trimmed, "\\") { + cur.WriteString(strings.TrimSuffix(trimmed, "\\") + " ") + continue + } + cur.WriteString(trimmed) + logical = append(logical, cur.String()) + cur.Reset() + } + for _, l := range logical { + if strings.HasPrefix(l, "[") { + continue + } + key, value, ok := strings.Cut(l, "=") + if !ok { + t.Fatalf("malformed unit line %q", l) + } + out[key] = append(out[key], value) + } + return out +} + +func TestSystemdUnitStateIsReachableUnderHardening(t *testing.T) { + unit := parseSystemdUnit(t, string(readRepoFile(t, "packaging/systemd/rho-daemon.service"))) + one := func(key string) string { + t.Helper() + if len(unit[key]) != 1 { + t.Fatalf("want exactly one %s=, got %q", key, unit[key]) + } + return unit[key][0] + } + + // In a system unit %h is always /root (systemd.unit(5)), and + // ProtectHome=true makes /home, /root and /run/user inaccessible, so no + // path the daemon needs may live there. + if one("ProtectHome") == "true" { + for _, key := range []string{"ReadWritePaths", "ReadOnlyPaths", "BindPaths", "BindReadOnlyPaths", "WorkingDirectory", "Environment"} { + for _, v := range unit[key] { + for _, hidden := range []string{"%h", "/home", "/root", "/run/user"} { + if strings.Contains(v, hidden) { + t.Errorf("%s=%s points under %s, which ProtectHome=true hides", key, v, hidden) + } + } + } + } + } + + if one("User") == "" || one("User") == "root" { + t.Error("the daemon must run as a dedicated unprivileged User=") + } + state := one("StateDirectory") + env := map[string]string{} + for _, e := range unit["Environment"] { + k, v, _ := strings.Cut(e, "=") + env[k] = v + } + if want := "/var/lib/" + state; env["HOME"] != want || !strings.HasPrefix(env["RHO_STATE_DIR"], want) || !strings.HasPrefix(env["RHO_CONFIG_DIR"], want) { + t.Errorf("HOME/RHO_STATE_DIR/RHO_CONFIG_DIR must live in the StateDirectory %s, got %v", want, env) + } + if _, ok := env["RHO_DAEMON_API_KEY"]; ok { + t.Error("do not set RHO_DAEMON_API_KEY inline; load it from the EnvironmentFile") + } + + exec := one("ExecStart") + if strings.Contains(exec, "--api-key") { + t.Errorf("ExecStart passes the API key on the command line (visible in ps): %s", exec) + } + if !strings.Contains(exec, " daemon start ") || !strings.Contains(exec, "--host 127.0.0.1") { + t.Errorf("ExecStart should start the daemon on loopback by default: %s", exec) + } + + for _, doc := range strings.Fields(one("Documentation")) { + if !strings.HasPrefix(doc, "https://github.com/GrayCodeAI/rho/") { + t.Errorf("Documentation=%s must point at the rho repository", doc) + } + } +} diff --git a/packaging/systemd/rho-daemon.service b/packaging/systemd/rho-daemon.service index 736323854..6fa311c2a 100644 --- a/packaging/systemd/rho-daemon.service +++ b/packaging/systemd/rho-daemon.service @@ -1,42 +1,60 @@ [Unit] -Description=Rho Daemon (code intelligence server) -Documentation=https://docs.rho.ai +Description=Rho daemon (local HTTP API for the Rho coding agent) +Documentation=https://github.com/GrayCodeAI/rho/blob/main/docs/troubleshooting-guide.md#systemd-issues After=network-online.target Wants=network-online.target [Service] Type=simple -# Replace with your installed binary path. When installed via the package -# manager, the binary is typically at /usr/local/bin/rho. -ExecStart=/usr/local/bin/rho daemon start \ - --host 127.0.0.1 \ - --port 4590 \ - --api-key ${RHO_DAEMON_API_KEY} \ - --log-level INFO -# For production, bind to 0.0.0.0 behind a TLS-terminating reverse proxy: -# ExecStart=/usr/local/bin/rho daemon start --host 0.0.0.0 --port 4590 --api-key ${RHO_DAEMON_API_KEY} --tls-cert /etc/rho/tls.crt --tls-key /etc/rho/tls.key +# Runs as a dedicated system user. Create it once: +# sudo useradd --system --home-dir /var/lib/rho --shell /usr/sbin/nologin rho +User=rho +Group=rho + +# All rho data lives in directories systemd creates and chowns to User=: +# /var/lib/rho/config settings (RHO_CONFIG_DIR) +# /var/lib/rho/state sessions, security log, run/daemon.log (RHO_STATE_DIR) +# /var/cache/rho disposable cache (RHO_CACHE_DIR) +# HOME points at /var/lib/rho so anything else resolved from the home +# directory (for example Flux's provider config under ~/.config/flux) lands +# there too. Home directories stay hidden by ProtectHome=true below. +StateDirectory=rho +StateDirectoryMode=0750 +CacheDirectory=rho +WorkingDirectory=/var/lib/rho +Environment=HOME=/var/lib/rho +Environment=RHO_CONFIG_DIR=/var/lib/rho/config +Environment=RHO_STATE_DIR=/var/lib/rho/state +Environment=RHO_CACHE_DIR=/var/cache/rho +Environment=RHO_ENABLE_TELEMETRY=0 +Environment=RHO_DAEMON_MAX_CONCURRENT=4 + +# Optional, root-owned, mode 0600: RHO_DAEMON_API_KEY= and any other +# settings. The daemon reads the key from its environment, so it never appears +# on the command line (visible in `ps`). Without a key the daemon generates one. +EnvironmentFile=-/etc/rho/daemon.env + +# Point this at your rho binary (install.sh installs to ~/.rho/bin/rho; copy +# that file to /usr/local/bin/rho for a system service). +ExecStart=/usr/local/bin/rho daemon start --host 127.0.0.1 --port 4590 --log-level INFO +# To serve other hosts, prefer a TLS-terminating reverse proxy, or run +# rho daemon start --host 0.0.0.0 --port 4590 --tls-cert /etc/rho/tls.crt --tls-key /etc/rho/tls.key Restart=always RestartSec=5 TimeoutStopSec=10 -# Security hardening (process-level confinement for the daemon). +# Hardening. The file system is read-only except the directories above and a +# private /tmp. Grant access to project checkouts the agent may edit with, e.g.: +# ReadWritePaths=/srv/projects NoNewPrivileges=true ProtectSystem=strict ProtectHome=true PrivateTmp=true -ReadWritePaths=%h/.rho/state /tmp -# Read-only access to config and tool directories. -ReadOnlyPaths=%h/.rho # Resource limits. LimitNOFILE=65536 MemoryMax=4G CPUQuota=200% -# Environment. -Environment=RHO_DAEMON_API_KEY= -Environment=RHO_ENABLE_TELEMETRY=0 -Environment=RHO_DAEMON_MAX_CONCURRENT=4 - [Install] WantedBy=multi-user.target From 3383ff07a673632a23ef87528034ff91452a8dbe Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:57:03 +0530 Subject: [PATCH 12/16] chore: remove the unenforced ast-grep configuration sgconfig.yaml pointed testDirs at a tests/ directory that does not exist, and the setup never enforced anything: - ast-grep (0.39.5, checked locally) only discovers sgconfig.yml, so `sg scan`/`sg test` in the repo root fail with "No ast-grep project configuration is found"; with `-c sgconfig.yaml`, `sg test` runs 0 tests; - the lefthook hook ran `sg scan` only when sg happened to be installed, piped through `head`, so it could never block a commit; - CI never ran it; - its single rule (no fmt.Println outside tests) contradicts the CLI, which prints user output with fmt.Println in ~178 places; the real guard for internal/ is testaudit's TestNoRawFmtPrintInInternal. Delete sgconfig.yaml, rules/ and the lefthook hook rather than imply an enforcement that does not exist. Refs: F033 Co-Authored-By: Claude Opus 5.5 --- lefthook.yml | 12 ------------ rules/no-fmt-println.yaml | 9 --------- sgconfig.yaml | 4 ---- 3 files changed, 25 deletions(-) delete mode 100644 rules/no-fmt-println.yaml delete mode 100644 sgconfig.yaml diff --git a/lefthook.yml b/lefthook.yml index 95f830d14..380b288c2 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -56,18 +56,6 @@ pre-commit: fi stage_fixed: true - ast-grep: - glob: "*.go" - run: | - # Run ast-grep structural code search on staged Go files. - # Falls back gracefully if `sg` (ast-grep) is not installed. - STAGED=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.go$' || true) - if [ -n "$STAGED" ]; then - if command -v sg >/dev/null 2>&1; then - echo "$STAGED" | xargs sg scan 2>&1 | head -20 - fi - fi - yaml-lint: glob: "*.{yml,yaml}" run: | diff --git a/rules/no-fmt-println.yaml b/rules/no-fmt-println.yaml deleted file mode 100644 index 19b07d9e6..000000000 --- a/rules/no-fmt-println.yaml +++ /dev/null @@ -1,9 +0,0 @@ -id: no-fmt-println -message: Use log.Printf or log.Println instead of fmt.Println in production code -severity: warning -language: Go -rule: - pattern: fmt.Println($$$) - not: - inside: - pattern: func Test$$$($$$) { $$$ } diff --git a/sgconfig.yaml b/sgconfig.yaml deleted file mode 100644 index 62a885e90..000000000 --- a/sgconfig.yaml +++ /dev/null @@ -1,4 +0,0 @@ -ruleDirs: - - rules -testDirs: - - tests From 72a7138ec7ebca6ba83b3cd0851c6efde2c01070 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 03:59:03 +0530 Subject: [PATCH 13/16] docs(release): document the manual, tag-protected release process CONTRIBUTING.md, README.md, docs/versioning.md, docs/compatibility.md, the compatibility matrix, cmd/rho/main.go and the shared templates all said release-please bumps VERSION, writes CHANGELOG.md and tags releases, and told contributors never to edit either file. No release-please config, manifest or workflow exists; releases are tags pushed by hand and CHANGELOG entries are hand-written. - New docs/RELEASING.md: the release PR checklist (VERSION, CHANGELOG section, compatibility matrix, README install URL), tagging, what release.yml does, how to verify a release, the status of every distribution channel (Homebrew, npm and Nix explicitly not available, with what each would need), and v0.3.0 specifics (hawk-era tags, retraction, "Latest" release). - Documents the existing tag ruleset protect-release-tags (id 24053191): creation/update/deletion/non-fast-forward of refs/tags/v* restricted, repository admin role as the only bypass. - Every release-please claim is replaced with the manual process; the PR checklist now asks for an [Unreleased] CHANGELOG entry instead of forbidding CHANGELOG edits. - `make release` no longer runs `goreleaser release --clean` from a workstation (an unsigned publish with a personal token, which is how the hawk-era v0.2.0 assets were produced); it explains the tag-driven process and exits 1. New `make release-check` (the release.yml guard) and `make release-snapshot` (goreleaser check + snapshot build, no publish or signing) replace it for local validation. Refs: F026, F028, F269 Co-Authored-By: Claude Opus 5.5 --- .shared-templates/Makefile.binary.tmpl | 5 +- .shared-templates/Makefile.python.tmpl | 2 +- .../workflows/go-lib-release.yml.tmpl | 2 +- .../workflows/python-release.yml.tmpl | 2 +- CONTRIBUTING.md | 24 ++-- Makefile | 21 ++- README.md | 2 +- cmd/rho/main.go | 7 +- docs/RELEASING.md | 135 ++++++++++++++++++ docs/compatibility.md | 4 +- docs/versioning.md | 15 +- testdata/compatibility-matrix.json | 2 +- 12 files changed, 186 insertions(+), 35 deletions(-) create mode 100644 docs/RELEASING.md diff --git a/.shared-templates/Makefile.binary.tmpl b/.shared-templates/Makefile.binary.tmpl index e9b055e55..1646cc356 100644 --- a/.shared-templates/Makefile.binary.tmpl +++ b/.shared-templates/Makefile.binary.tmpl @@ -61,8 +61,9 @@ build: ## Build the binary into bin/$(NAME). install: ## Install the binary to $GOBIN. go install -ldflags "$(LDFLAGS)" $(MAIN_PKG) -release: ## Cut a release via goreleaser (requires a clean tree + tag). - goreleaser release --clean +release: ## Refuse local publishing: releases are published by CI on a v* tag. + @echo "Releases are CI-only: merge the release PR (VERSION + CHANGELOG), then push tag v$(VERSION)." + @exit 1 # --------------------------------------------------------------------------- # Tests. diff --git a/.shared-templates/Makefile.python.tmpl b/.shared-templates/Makefile.python.tmpl index 03cad0870..24b1a6720 100644 --- a/.shared-templates/Makefile.python.tmpl +++ b/.shared-templates/Makefile.python.tmpl @@ -9,7 +9,7 @@ NAME := {{NAME}} # --------------------------------------------------------------------------- # Versioning — sourced from VERSION file at repo root (single source of -# truth, also consumed by hatch + release-please). +# truth, also consumed by hatch). # --------------------------------------------------------------------------- VERSION ?= $(shell v=$$(cat VERSION 2>/dev/null | head -n1 | tr -d '[:space:]'); [ -n "$$v" ] && echo "$$v" || echo "dev") diff --git a/.shared-templates/workflows/go-lib-release.yml.tmpl b/.shared-templates/workflows/go-lib-release.yml.tmpl index 8e3d37cbc..89b32156c 100644 --- a/.shared-templates/workflows/go-lib-release.yml.tmpl +++ b/.shared-templates/workflows/go-lib-release.yml.tmpl @@ -1,5 +1,5 @@ # Release workflow for Go LIBRARY repos (no binaries, no goreleaser). -# Triggered by release-please when it pushes a v* tag. +# Triggered when a maintainer pushes a v tag after the release PR merges. # Source of truth: .shared-templates/workflows/go-lib-release.yml.tmpl # # Consumers depend on the tag directly via diff --git a/.shared-templates/workflows/python-release.yml.tmpl b/.shared-templates/workflows/python-release.yml.tmpl index df019e651..16750568b 100644 --- a/.shared-templates/workflows/python-release.yml.tmpl +++ b/.shared-templates/workflows/python-release.yml.tmpl @@ -1,5 +1,5 @@ # Canonical PyPI publish workflow for graycode-eco Python repos. -# Triggered by release-please when it pushes a v* tag. +# Triggered when a maintainer pushes a v tag after the release PR merges. # Source of truth: .shared-templates/workflows/python-release.yml.tmpl # # Uses PyPI Trusted Publishing (OIDC) — no API tokens stored in GitHub. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1fecc3482..085d7633a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,9 +46,10 @@ Repo-specific dev targets: ## Commit message convention -We use [Conventional Commits](https://www.conventionalcommits.org/). This -isn't cosmetic — release-please reads commit messages to bump the `VERSION` -file and generate the CHANGELOG, so getting them right matters. +We use [Conventional Commits](https://www.conventionalcommits.org/). Pull +request titles and commit subjects follow it so the history reads cleanly and +the release PR can group changes. There is no release bot: releases are cut by +hand (see [docs/RELEASING.md](docs/RELEASING.md)). ``` (): @@ -60,19 +61,19 @@ file and generate the CHANGELOG, so getting them right matters. **Types:** -- `feat:` — a new feature (triggers a minor version bump) -- `fix:` — a bug fix (triggers a patch version bump) +- `feat:` — a new feature +- `fix:` — a bug fix - `perf:` — performance improvement - `refactor:` — code restructure with no behaviour change - `docs:` — documentation only - `test:` — adding or fixing tests - `build:` — build system or dependencies - `ci:` — CI configuration -- `chore:` — anything else (no release effect) +- `chore:` — anything else - `revert:` — reverts a previous commit **Breaking changes:** add `!` after the type/scope or include `BREAKING -CHANGE:` in the footer. This triggers a major version bump. +CHANGE:` in the footer, and call the change out in `CHANGELOG.md`. Examples: @@ -120,10 +121,11 @@ Before requesting review: - [ ] `make ci` passes locally. - [ ] New behaviour has tests; bug fixes have a regression test. -- [ ] `CHANGELOG.md` entries are **not** edited manually — release-please - generates them from your commit messages. -- [ ] The `VERSION` file is **not** edited manually — release-please bumps - it on release. +- [ ] User-visible changes have an entry under `## [Unreleased]` in + `CHANGELOG.md` (Keep a Changelog: Added / Changed / Fixed / Removed / + Security). +- [ ] `VERSION` is left alone — it changes only in a release PR + ([docs/RELEASING.md](docs/RELEASING.md)). - [ ] Public API changes have updated doc comments. - [ ] No secrets, API keys, or PII in code, comments, tests, or fixtures. diff --git a/Makefile b/Makefile index 9cd05f123..266adec94 100644 --- a/Makefile +++ b/Makefile @@ -31,13 +31,12 @@ GOLANGCI_VERSION := v2.1.0 GOFUMPT := $(GOBIN_DIR)/gofumpt GOIMPORTS := $(GOBIN_DIR)/goimports GOVULNCHECK := $(GOBIN_DIR)/govulncheck -GORELEASER := $(GOBIN_DIR)/goreleaser # --------------------------------------------------------------------------- # Phony declarations (alphabetical). # --------------------------------------------------------------------------- .PHONY: all bench boundaries build check-replace ci clean ecosystem-guard feature-boundaries-guard flux-client-guard flux-engine-guard manifest-guard peer-guard internal-layers-guard package-boundaries-guard release-parity cover cover-new fmt help install lint lint-fix \ - release security setup smoke path sync test test-10x test-live test-new test-race tidy version vet api-docs api-validate workspace + release release-check release-snapshot security setup smoke path sync test test-10x test-live test-new test-race tidy version vet api-docs api-validate workspace check-replace: ## Fail if go.mod has local replace directives (run before tagging) @bash scripts/check-no-replace-directives.sh @@ -56,9 +55,21 @@ build: ## Build the binary into bin/$(NAME). install: ## Install the binary to $GOBIN. CGO_ENABLED=0 go install -trimpath -ldflags="$(LDFLAGS)" $(MAIN_PKG) -release: ## Cut a release via goreleaser (requires a clean tree + tag). - @command -v $(GORELEASER) >/dev/null 2>&1 || (echo "install: go install github.com/goreleaser/goreleaser/v2@latest" && exit 1) - $(GORELEASER) release --clean +# Releases are published only by .github/workflows/release.yml on a v* tag +# (signed with the workflow's OIDC identity). Local targets validate; they +# never publish. See docs/RELEASING.md. +release: ## Refuse local publishing; explains the tag-driven release process. + @echo "Releases are CI-only: merge the release PR (VERSION + CHANGELOG), then push tag v$(VERSION)." + @echo "Local checks: make release-check TAG=v$(VERSION) && make release-snapshot. See docs/RELEASING.md." + @exit 1 + +release-check: ## Check TAG (default v$(VERSION)) against VERSION and CHANGELOG.md, as release.yml does. + @bash ./scripts/check-release-tag.sh "$(or $(TAG),v$(VERSION))" + +release-snapshot: ## Build all release archives locally into dist/ (no publish, no signing). + @command -v goreleaser >/dev/null 2>&1 || (echo "install: go install github.com/goreleaser/goreleaser/v2@v2.17.0" && exit 1) + goreleaser check + GOWORK=off goreleaser release --snapshot --clean --skip=publish,sign # --------------------------------------------------------------------------- # Tests. diff --git a/README.md b/README.md index 6dfc24946..8a90fd0de 100644 --- a/README.md +++ b/README.md @@ -540,7 +540,7 @@ Quick start: 3. Run `make ci` locally 4. Open a pull request -Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages — release-please uses them for versioning. +Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages. Releases are cut by hand from a release PR; see [docs/RELEASING.md](docs/RELEASING.md). ## License diff --git a/cmd/rho/main.go b/cmd/rho/main.go index e6d43c167..fa7e5e433 100644 --- a/cmd/rho/main.go +++ b/cmd/rho/main.go @@ -20,9 +20,10 @@ import ( // Version, Commit, and BuildDate are set at build time via ldflags. // -// Source of truth: the VERSION file at the repo root, and the matching git -// tag created by release-please. The Makefile and goreleaser inject these -// values during release builds: +// Source of truth: the VERSION file at the repo root, and the matching +// v git tag pushed by a maintainer (release.yml refuses a tag that +// disagrees with VERSION; see docs/RELEASING.md). The Makefile and goreleaser +// inject these values during builds: // // -X main.Version=$(cat VERSION) // -X main.Commit=$(git rev-parse --short HEAD) diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 000000000..b6aa8dde6 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,135 @@ +# Releasing rho + +Rho is pre-1.0. Releases are cut by hand: a maintainer merges a release PR +that sets `VERSION` and the `CHANGELOG.md` section, then pushes the matching +`v` tag. The tag triggers +[`.github/workflows/release.yml`](../.github/workflows/release.yml), which +builds, signs and publishes the GitHub release. There is no release-please or +other bot: nothing bumps `VERSION` or writes `CHANGELOG.md` automatically. + +The first release under the `rho` name is **v0.3.0**. + +## Tag protection + +Only repository admins can create, move or delete release tags. The +repository ruleset **`protect-release-tags` (id 24053191)** is active for +tags matching `refs/tags/v*` and restricts creation, update, deletion and +non-fast-forward updates, with the repository admin role as the only bypass +actor. A push of a `v*` tag by anyone else is rejected, so a write-access +account cannot trigger a signed release. Inspect it with: + +```bash +gh api repos/GrayCodeAI/rho/rulesets/24053191 +``` + +The `main` branch is separately protected (required status checks, no force +pushes). + +## Release checklist + +1. **Release PR** (conventional title, e.g. `chore(release): v0.3.0`): + - `VERSION` holds `X.Y.Z` (no leading `v`). + - `CHANGELOG.md`: rename `## [Unreleased]` to `## [X.Y.Z] — YYYY-MM-DD` + and add a new, empty `## [Unreleased]` above it. This section becomes the + GitHub release notes verbatim. + - `testdata/compatibility-matrix.json`: set the `stable` matrix to rho + `X.Y.Z` and the flux version pinned in `go.mod`. + - `README.md` install section: point the `install.sh` URL at the new tag. + - Run locally: + + ```bash + make release-check TAG=vX.Y.Z # tag == v$(cat VERSION), CHANGELOG section present + make release-snapshot # goreleaser check + all archives into dist/, no publish/sign + ``` + +2. **Merge** the PR and wait for CI on `main` to pass. +3. **Tag** the merge commit (admins only, see above): + + ```bash + git fetch origin + git tag -s vX.Y.Z -m "rho vX.Y.Z" + git push origin vX.Y.Z + ``` + +4. **Watch** the `release` workflow run to completion (next section). +5. **Verify** the published release as a user would (see + [Verifying a release](#verifying-a-release)), including + `curl -fsSL https://raw.githubusercontent.com/GrayCodeAI/rho/vX.Y.Z/install.sh | RHO_REQUIRE_COSIGN=1 sh` + and `go install github.com/GrayCodeAI/rho/cmd/rho@vX.Y.Z`. + +`make release` deliberately refuses to publish from a workstation: releases are +signed with the workflow's GitHub OIDC identity, which a local run cannot have. + +## What the release workflow does + +On a pushed `v*` tag, with `permissions: contents: write, id-token: write`: + +1. `scripts/check-release-tag.sh` refuses the run unless the tag is exactly + `v` + `VERSION` and `CHANGELOG.md` has a non-empty `## []` + section; that section is extracted as the release notes. +2. `scripts/check-no-replace-directives.sh` refuses local `replace` + directives. The job sets `GOWORK=off`, so dependencies (including flux) + come only from `go.mod`/`go.sum` via the public proxy. +3. syft (`anchore/sbom-action/download-syft`) and cosign + (`sigstore/cosign-installer`) are installed; both actions are pinned by + commit SHA. +4. GoReleaser v2.17.0 (`.goreleaser.yml`) builds and publishes: + - `rho___.tar.gz` for linux and darwin and `.zip` for + windows, each on amd64 and arm64 (six archives, `-trimpath`, `CGO_ENABLED=0`); + - `rho__source.tar.gz`; + - one SPDX SBOM per archive (`.spdx.sbom.json`); + - `checksums.txt` (SHA-256 of every asset above); + - `checksums.txt.sigstore.json`, a cosign keyless Sigstore bundle + (certificate, signature and transparency-log proof) over `checksums.txt`. +5. The workflow re-downloads `checksums.txt` and the bundle from the published + release and verifies them with the exact certificate identity, so a broken + signature fails the run visibly. + +## Verifying a release + +```bash +tag=vX.Y.Z +gh release download "$tag" --repo GrayCodeAI/rho \ + --pattern checksums.txt --pattern checksums.txt.sigstore.json \ + --pattern "rho_${tag#v}_linux_amd64.tar.gz" +cosign verify-blob --bundle checksums.txt.sigstore.json \ + --certificate-identity "https://github.com/GrayCodeAI/rho/.github/workflows/release.yml@refs/tags/$tag" \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums.txt +sha256sum --ignore-missing -c checksums.txt +``` + +`install.sh` performs the same checks: it always verifies the SHA-256, verifies +the bundle when cosign (v2.4 or later; v3 recommended) is installed, says so +plainly when it is not, and refuses to install without cosign when +`RHO_REQUIRE_COSIGN=1`. + +## Distribution channels + +| Channel | Status | +|---|---| +| GitHub release archives | From v0.3.0. | +| `install.sh` | From v0.3.0; refuses older (hawk-era) versions. | +| `go install github.com/GrayCodeAI/rho/cmd/rho@latest` | From v0.3.0. Until then `@main` works; `@latest` resolves to the hawk-era v0.2.0. | +| GitHub Action (`.github/actions/rho`) | From v0.3.0; installs through `install.sh` with cosign required. | +| systemd unit (`packaging/systemd/rho-daemon.service`) | Template for running `rho daemon`; see the troubleshooting guide. | +| Homebrew | **Not available.** Needs a `GrayCodeAI/homebrew-tap` repository, a fine-grained token with write access to it stored as a secret, and a `homebrew_casks` stanza in `.goreleaser.yml`. | +| npm | **Not available.** The scaffolding in `npm/` is marked private; see [`npm/README.md`](../npm/README.md). | +| Nix | **Not available.** The old flake could not evaluate and was removed; a new one needs a computed `vendorHash`, a committed `flake.lock` and `nix flake check` in CI. | + +Add a channel to the README only after it has worked end to end for a real +release. + +## Notes for v0.3.0 (first rho release) + +- Tags `v0.1.0`, `v0.1.1` and `v0.2.0` were published under the previous + module path `github.com/GrayCodeAI/hawk`; their assets are `hawk_*` archives + containing a `hawk` binary. None of them is an ancestor of `main`, and + `v0.1.1` has no GitHub release. The Go proxy keeps serving them, so v0.3.0's + `go.mod` retracts `[v0.1.0, v0.2.0]`. +- Because no earlier tag is reachable from `main`, the release notes come only + from the `CHANGELOG.md` section and the "Full changelog" compare link is + omitted. +- v0.2.0 is the current "Latest" release. Once v0.3.0 is published it becomes + Latest. Consider editing the v0.2.0 release notes to say it is the legacy + hawk build and cannot be installed as rho. diff --git a/docs/compatibility.md b/docs/compatibility.md index 52a98a0b9..ccc9e627e 100644 --- a/docs/compatibility.md +++ b/docs/compatibility.md @@ -34,8 +34,8 @@ existing consumers. ## When to update -- **`next` updates automatically** — release-please bumps `main` references - as components advance. No manual edit needed. +- **`next` needs no edits** — it always names each component's `main` + branch. - **`stable` is updated by hand** — when you cut a coordinated release of the eco. The release engineer: 1. Verifies CI green on `next` matrix. diff --git a/docs/versioning.md b/docs/versioning.md index f8e6c2802..fc6339889 100644 --- a/docs/versioning.md +++ b/docs/versioning.md @@ -34,8 +34,9 @@ repository that follows this layout. Adopted 2026-05-14. -X main.Commit=$(COMMIT) \ -X main.BuildDate=$(DATE) ``` -- `goreleaser` injects from the git tag at release time (which always matches - the `VERSION` file because release-please bumps both atomically). +- `goreleaser` injects from the git tag at release time. The release workflow + refuses a tag that is not exactly `v` + `VERSION` + (`scripts/check-release-tag.sh`), so the two cannot disagree. - This is the Kubernetes / Helm / gh-cli pattern. ### Go libraries (`flux`) @@ -107,11 +108,11 @@ func init() { ## Bumping a version -Use [release-please](https://github.com/googleapis/release-please) (per repo). -It reads conventional commits, bumps `VERSION`, updates `CHANGELOG.md`, and -creates a git tag — all atomically. Goreleaser then runs on the tag. - -For manual bumps, edit the `VERSION` file. Don't edit anything else. +Versions are bumped by hand in a release PR: edit `VERSION`, turn the +`## [Unreleased]` section of `CHANGELOG.md` into `## [X.Y.Z] — YYYY-MM-DD`, +merge, then push the `vX.Y.Z` tag. There is no release-please or other bot. +For rho the full procedure, tag protection and verification steps are in +[RELEASING.md](RELEASING.md). ## Cross-repo compatibility diff --git a/testdata/compatibility-matrix.json b/testdata/compatibility-matrix.json index b3fb7e3ef..54eeb48bf 100644 --- a/testdata/compatibility-matrix.json +++ b/testdata/compatibility-matrix.json @@ -24,7 +24,7 @@ }, { "name": "next", - "description": "Combination that the upcoming release will ship — kept up to date by release-please as components advance. Tested in CI on every PR.", + "description": "Combination that the upcoming release will ship: each component's main branch. Tested in CI on every PR.", "components": { "rho": "main", "flux": "main" From 3610e43b63b82967615ba72003945bac31a4624f Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:00:17 +0530 Subject: [PATCH 14/16] docs(changelog): repair the version history and record this release work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CHANGELOG.md listed [0.2.0] (2026-07-13) above [0.4.0] and [0.3.0] (May 2026), so versions were not monotonic, and it already had a "## [0.3.0]" heading for hawk-era work although v0.3.0 is the next rho release (the release guard would have accepted that stale section as the v0.3.0 notes). [Unreleased] had two "### Changed" lists. - A short history note explains that tags v0.1.0, v0.1.1 and v0.2.0 were built from the github.com/GrayCodeAI/hawk module and that v0.3.0 is the first rho release. - Historical sections keep their text but are relabelled "[hawk 0.2.0]" (the v0.2.0 tag) and "[hawk X.Y.Z, untagged]" (earlier numbering that was never tagged), so no heading can be mistaken for a rho version. - "Flux v0.2.1" is annotated as the eyrie version of the time, and the internal/jsonc, internal/providers and internal/permissions/verdict.go bullets are marked "(since removed)". - The two [Unreleased] "### Changed" lists are merged, and this PR's release, install, packaging and removal changes are recorded. testaudit's TestChangelogStructure requires a single leading [Unreleased], release headings of the form "## [X.Y.Z] — YYYY-MM-DD" in descending order and not above VERSION, "[hawk …]" for historical entries, and unique subsection headings per section. The "## [0.3.0] — " section itself is written in the release PR right before tagging (docs/RELEASING.md); until then `make release-check` and release.yml refuse the v0.3.0 tag. Refs: F270 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 51 +++++++++++++++------ internal/testaudit/changelog_test.go | 68 ++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 internal/testaudit/changelog_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 9421dc237..7c4578dfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,10 +4,30 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Releases are cut by hand from a release PR; see [docs/RELEASING.md](docs/RELEASING.md). + +> **Version history.** The next release, **v0.3.0**, is the first published +> under the `rho` name and module path `github.com/GrayCodeAI/rho`. Sections +> labelled `hawk` below are kept as the historical record of the project +> before the rename: `hawk 0.2.0` is the `v0.2.0` tag, which (like `v0.1.0` +> and `v0.1.1`) was built from the `github.com/GrayCodeAI/hawk` module and +> ships `hawk_*` archives; the sections marked "untagged" used an earlier +> numbering that was never tagged. Names in those sections are the names of +> the time (for example Flux was called eyrie, and several companion repos +> have since been retired). ## [Unreleased] +### Added +- **Release process and guards**: `docs/RELEASING.md` (release PR checklist, tag protection, what the release workflow publishes, how to verify a release, status of every distribution channel); `scripts/check-release-tag.sh`, used by the release workflow and `make release-check`, refuses a tag that is not `v` + `VERSION` or has no `CHANGELOG.md` section; `make release-snapshot` builds every archive locally without publishing or signing. +- **windows/arm64 release archives**: GoReleaser and the CI build matrix now cover all six linux/darwin/windows × amd64/arm64 targets. + ### Changed +- **Version 0.3.0**: `VERSION` is `0.3.0`, the first release under the rho name (the Go proxy already holds the hawk-era v0.1.0–v0.2.0), and the compatibility matrix `stable` entry pins rho 0.3.0 with flux 0.0.1. +- **Release workflow can publish**: it installs syft for the SBOMs, signs `checksums.txt` inside GoReleaser with cosign keyless signing (`checksums.txt.sigstore.json`), re-verifies the published signature, takes the release notes from this file, builds with `GOWORK=off` and `-trimpath`, and drops the unused `packages: write` permission and sibling checkout. +- **Binary size budget enforced**: `make size-check` fails above `SIZE_LIMIT_MB` (70 MiB; linux/amd64 is 59.6 MiB today) and CI runs it instead of only warning above 98 MB. +- **`make release` no longer publishes** from a workstation; releases come only from the tag-triggered workflow. +- **npm scaffolding kept unpublished**: packages are marked `"private": true` with the correct MIT license, and assembly stamps the meta package and its `optionalDependencies` and reads GoReleaser v2's output paths. Nothing is published to npm. - **Dependency renamed eyrie → flux**: rho now depends on `github.com/GrayCodeAI/flux v0.0.1` (the provider runtime was renamed). All imports, env vars (`EYRIE_CONFIG_DIR` → `FLUX_CONFIG_DIR`, @@ -15,8 +35,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 scripts, ecosystem manifest entries, docs, and internal identifiers were renamed to match. User state paths move from `~/.../eyrie/provider.json` to `~/.../flux/provider.json`. +- **Makefile lint pin matches CI**: `make lint`/`lint-fix`/`setup` install `golangci-lint@v2.1.0` (was `@latest`), the same version CI enforces. +- **Docs truth and housekeeping**: SECURITY.md/CONTRIBUTING.md now describe the actual Go toolchain (golangci-lint, go vet, govulncheck) instead of the polyglot template's ruff/mypy/pip-audit/pnpm-lock language, CONTRIBUTING documents `make setup`/`boundaries`/`test-10x`/`smoke`, and the planning docs (`SPEC_DRIVEN_PLAN.md`, `SPEC_DRIVEN_PHASE2_PLAN.md`, `internal/engine/REFACTOR_PLAN.md`) moved to `docs/plans/`. ### Security +- **`install.sh` verifies releases fail-closed**: with cosign installed it verifies `checksums.txt.sigstore.json` against the exact release-workflow identity and aborts on a missing or invalid signature; without cosign it warns and reports the signature as not verified, and `RHO_REQUIRE_COSIGN=1` makes cosign mandatory. The SHA-256 check always runs, flags are parsed strictly (unknown flags exit 2), and releases older than v0.3.0 (hawk-era archives) are refused. +- **GitHub Action installs signed release archives** through `install.sh` with cosign required, instead of building an unverified `curl | tar` source tarball; `rho-version` accepts `0.3.0`, `v0.3.0` or `latest`, and Go is no longer needed. +- **systemd unit**: runs as a dedicated `rho` user with state in `StateDirectory`/`CacheDirectory` (the old `%h` paths were hidden by `ProtectHome=true`), reads the daemon API key from an `EnvironmentFile` instead of the command line, and links the troubleshooting guide instead of a dead domain. - **Session lock TOCTOU eliminated**: `AcquireLock`'s stat → stale-if->5min → remove → O_EXCL dance could delete a live lock on misjudged staleness and let two instances open the same session. Mutual exclusion now uses an OS advisory lock (`gofrs/flock`, promoted to a direct dependency); a crashed holder's lock is reclaimed instantly because the kernel drops the flock at process death. The lock file keeps PID/timestamps purely as diagnostics. - **Hardened atomic writes for state files**: global settings, checkpoint file contents and restores, handovers, and named checkpoints now go through `internal/safewrite` (same 0600 mode as before, plus fsync+rename atomicity and symlink refusal at the destination). - **Dead shell-injection surface removed**: `AssumptionTracker.VerifyCommandSucceeds` ran caller-supplied strings through `sh -c`, bypassing the permission stack; it had zero callers and is deleted. `SelfHealer.RunScript` no longer shell-evaluates the script path (double evaluation) and invokes it directly via `/bin/sh`. @@ -25,21 +50,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Engine subprocesses are bounded and observable**: experiment-loop rollback, auto-commit git calls, and post-edit syntax validators (`go vet`, `python3`, `node`, `npx tsc`) ran on `context.Background()` with ignored errors; they are now time-bounded and log failures instead of discarding them. - **Memory and config failures no longer silently dropped**: stream-loop memory persists (assistant learnings, skills, conversation summaries, insights) and the self-improve lesson store log failures via slog, corrupt lesson stores are reported, and the config panel surfaces failed `provider` setting saves instead of ignoring them. -### Changed -- **Makefile lint pin matches CI**: `make lint`/`lint-fix`/`setup` install `golangci-lint@v2.1.0` (was `@latest`), the same version CI enforces. -- **Docs truth and housekeeping**: SECURITY.md/CONTRIBUTING.md now describe the actual Go toolchain (golangci-lint, go vet, govulncheck) instead of the polyglot template's ruff/mypy/pip-audit/pnpm-lock language, CONTRIBUTING documents `make setup`/`boundaries`/`test-10x`/`smoke`, and the planning docs (`SPEC_DRIVEN_PLAN.md`, `SPEC_DRIVEN_PHASE2_PLAN.md`, `internal/engine/REFACTOR_PLAN.md`) moved to `docs/plans/`. - ### Removed +- **Homebrew publisher and install instructions**: the `brews` stanza (deprecated in GoReleaser v2) targeted a `GrayCodeAI/homebrew-tap` repository and token that do not exist; the README no longer offers Homebrew or npm installs. +- **`flake.nix` and its `.envrc`**: the flake depended on five deleted repositories and could not evaluate. +- **ast-grep configuration** (`sgconfig.yaml`, `rules/`, lefthook hook): ast-grep never discovered the config and CI never ran it. - **BREAKING — `rho credentials migrate` removed**: the subcommand, its man page entry, and the `MigrateEnvFileCredentials` wrappers are gone, and `rho path` no longer reports legacy `~/.rho/env` / `~/.rho/.env` files. Save keys through `/config`. - **BREAKING — settings model/provider migration removed**: `LoadSettings` no longer moves `model`/`provider` from `settings.json` into Flux's `provider.json`, and `SetActiveSelection` is deleted. Stale `model`/`provider` values left in `settings.json` are now ignored on load (Flux's selection wins; `--settings` overrides still apply); select the model in `/config`. - **BREAKING — startup provider-secrets migration removed**: print/REPL/watch/TUI startup no longer calls `MigrateProviderSecrets`, and the method is dropped from the gateway `CatalogMaintenance` interface. `rho path` still fails when `provider.json` holds secrets; remove those fields manually. - **BREAKING — `shared/types` guards removed**: `scripts/check-shared-types-imports.sh`, the `contracts-guard` make target, its lefthook and CI steps, and the matching testaudit checks are deleted because the package no longer exists. The ecosystem boundary guards still block `rho/internal` imports. -## [0.2.0] — 2026-07-13 +## [hawk 0.2.0] — 2026-07-13 ### Changed - **Rho/Flux production boundary completed**: Rho owns the product face, - sessions, tools, permissions, and public schemas while Flux v0.2.1 owns + sessions, tools, permissions, and public schemas while Flux v0.2.1 (then + named eyrie; flux itself has only tagged v0.0.1) owns credentials, catalog resolution, provider transport, resilience, and usage telemetry behind the stable `flux/engine` facade. - **Provider routing and usage attribution hardened**: resolved route changes, @@ -103,17 +128,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `O_NOFOLLOW` via `golang.org/x/sys/unix`, writes to a temp file with mode 0600, syncs to disk, then atomically renames. `ErrSymlinkTarget` and `ErrPathEscape` sentinel errors. -- **`internal/jsonc`** package: JSON-with-Comments parser and +- **`internal/jsonc`** package (since removed): JSON-with-Comments parser and `ValidateClaudeSettings` validator, native GrayCode settings parser and `validateHookFields`. Accepts `//` and `/* */` comments plus trailing commas in objects and arrays. Validates Claude Code `settings.json` fields (model, permissions, hooks, mcpServers, env) with type checks and value validation. -- **`internal/permissions/verdict.go`**: unified `PermissionVerdict` +- **`internal/permissions/verdict.go`** (since removed): unified `PermissionVerdict` type with Risk levels (`RiskLow`, `RiskMedium`, `RiskHigh`, `RiskBlocked`). Helpers: `Allow`, `Deny`, `RequireApproval`. Additive change — existing `GuardianDecision` is unchanged. -- **`internal/providers`** package: PROVIDERS matrix (34 entries) +- **`internal/providers`** package (since removed): PROVIDERS matrix (34 entries) native GrayCode PROVIDERS matrix. Each entry describes an AI coding agent (Claude Code, Cursor, Codex, Aider, etc.) with install mechanism and detection probes. Probe @@ -159,7 +184,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `update` package coverage raised from ~22% to ~92% with full HTTP mocking, including error paths (server failure, invalid JSON, unreachable host) and `Summary()` rendering. -## [0.4.0] — 2026-05-05 +## [hawk 0.4.0, untagged] — 2026-05-05 ### Added - **Exec Subcommand**: `rho exec "prompt"` — full engine non-interactive mode with `--output-format json`, `--auto` autonomy levels, `--worktree` isolation, `--agent` personas, `--session-id` resume, stdin piping @@ -184,7 +209,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - OpenCode (structured compaction, snapshot system, doom loop escalation) - Waza by tw93 (engineering-habit workflows: think, hunt, check, design) -## [0.3.0] — 2026-05-03 +## [hawk 0.3.0, untagged] — 2026-05-03 ### Added - **Model Cascade Router**: Cost-aware routing that classifies prompts and selects optimal model tier (simple→Haiku, debug→Sonnet, generation→Opus). Supports frugal mode for aggressive cost savings. Tracks routing decisions for analytics. @@ -203,7 +228,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `generateSummary()` now uses cheapest available model per provider instead of primary model - Ecosystem roadmap added: `ECOSYSTEM-ROADMAP.md` with 30-feature prioritized implementation plan -## [0.1.0] — 2026-05-01 +## [hawk 0.1.0, untagged] — 2026-05-01 ### Added - **Bash Security**: zsh bypass protection, process substitution blocking, IFS injection detection, carriage return prevention, ANSI-C quoting detection, git commit safety @@ -241,7 +266,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Stream-JSON usage events with token tracking - Pre-compiled regexes for performance -## [0.0.1] — 2026-04-30 +## [hawk 0.0.1, untagged] — 2026-04-30 ### Added - Project scaffold with cobra CLI and Bubbletea TUI diff --git a/internal/testaudit/changelog_test.go b/internal/testaudit/changelog_test.go new file mode 100644 index 000000000..da68c9e19 --- /dev/null +++ b/internal/testaudit/changelog_test.go @@ -0,0 +1,68 @@ +package testaudit + +import ( + "regexp" + "strings" + "testing" + + "golang.org/x/mod/semver" +) + +// CHANGELOG.md follows Keep a Changelog: one [Unreleased] section on top, +// released rho versions newest first, each with unique subsection headings. +// Entries from before the rename to rho are kept verbatim under "[hawk …]" +// headings so they cannot be mistaken for rho versions. +var ( + changelogReleaseHeading = regexp.MustCompile(`^## \[(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\] — \d{4}-\d{2}-\d{2}$`) + changelogHistoricalHeading = regexp.MustCompile(`^## \[hawk [^\]]+\]`) +) + +func TestChangelogStructure(t *testing.T) { + version := repoVersion(t) + lines := strings.Split(string(readRepoFile(t, "CHANGELOG.md")), "\n") + + var sections []string + subsections := map[string]map[string]bool{} + var current string + var lastRelease string + for i, line := range lines { + switch { + case strings.HasPrefix(line, "## "): + current = line + sections = append(sections, line) + subsections[line] = map[string]bool{} + switch { + case line == "## [Unreleased]": + case changelogReleaseHeading.MatchString(line): + v := "v" + changelogReleaseHeading.FindStringSubmatch(line)[1] + if semver.Compare(v, "v"+version) > 0 { + t.Errorf("CHANGELOG.md:%d: %s is newer than VERSION %s", i+1, line, version) + } + if lastRelease != "" && semver.Compare(v, lastRelease) >= 0 { + t.Errorf("CHANGELOG.md:%d: %s is not older than the section above it (%s)", i+1, line, lastRelease) + } + lastRelease = v + case changelogHistoricalHeading.MatchString(line): + default: + t.Errorf("CHANGELOG.md:%d: unexpected section heading %q (want [Unreleased], [X.Y.Z] — YYYY-MM-DD, or a [hawk …] historical entry)", i+1, line) + } + case strings.HasPrefix(line, "### ") && current != "": + if subsections[current][line] { + t.Errorf("CHANGELOG.md:%d: duplicate %q inside %q; merge the lists", i+1, line, current) + } + subsections[current][line] = true + } + } + if len(sections) == 0 || sections[0] != "## [Unreleased]" { + t.Fatalf("CHANGELOG.md must start with a single ## [Unreleased] section, got %q", sections) + } + unreleased := 0 + for _, s := range sections { + if s == "## [Unreleased]" { + unreleased++ + } + } + if unreleased != 1 { + t.Errorf("CHANGELOG.md has %d [Unreleased] sections, want 1", unreleased) + } +} From 72bddcc61f0aa783e4c7d49bcf1c238ad1574443 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:02:39 +0530 Subject: [PATCH 15/16] fix(version): report the tagged module version for go install builds `go install github.com/GrayCodeAI/rho/cmd/rho@vX.Y.Z` builds without ldflags, so main.Version stays "dev". DisplayVersion then fell back to any VERSION file in the working directory or its parents (so running rho inside another project could print that project's version) and otherwise "dev"; `rho update` compared the raw "dev" string, which never parses, and always reported an update. DisplayVersion now prefers, after the ldflags version, the main module version from debug.ReadBuildInfo when it is a clean release (e.g. v0.3.0 -> "0.3.0"); "(devel)", pseudo-versions (@main, untagged commits) and +dirty builds still fall back to the VERSION file, then "dev". `rho update` uses DisplayVersion, so go-install and source builds compare their real version. Tests stub the build info for a tagged module, each development form, and the ldflags precedence. Refs: F018, F025 Co-Authored-By: Claude Opus 5.5 --- cmd/root.go | 7 +++-- cmd/version_display.go | 34 ++++++++++++++++++++++++- cmd/version_display_test.go | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 6ad93d33f..045d4f074 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -585,10 +585,9 @@ var updateCmd = &cobra.Command{ Long: "Check GitHub for a newer rho release and print upgrade instructions.", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { - ver := version - if ver == "" { - ver = "dev" - } + // Same resolution as `rho --version`, so `go install ...@vX.Y.Z` and + // source builds compare their real version instead of "dev". + ver := DisplayVersion() prog := NewCLIProgress("Update check", []string{"Checking GitHub for updates"}) defer prog.Abort() prog.StartStep(0) diff --git a/cmd/version_display.go b/cmd/version_display.go index 4159c3601..d18ffc71d 100644 --- a/cmd/version_display.go +++ b/cmd/version_display.go @@ -3,7 +3,11 @@ package cmd import ( "os" "path/filepath" + "runtime/debug" "strings" + + "golang.org/x/mod/module" + "golang.org/x/mod/semver" ) // versionLine is the single user-facing version format shared by @@ -21,12 +25,20 @@ func versionLine() string { } // DisplayVersion returns the user-facing version string for banners and /version. -// Release builds inject version via ldflags; local builds fall back to VERSION file. +// +// In order of preference: +// 1. the version injected via ldflags (release archives, `make build`); +// 2. the module version recorded by `go install .../cmd/rho@vX.Y.Z`; +// 3. the repository VERSION file (local `go build` / `go run` checkouts); +// 4. "dev". func DisplayVersion() string { v := strings.TrimSpace(version) if v != "" && v != "dev" { return v } + if mv := releaseModuleVersion(); mv != "" { + return mv + } if fromFile := readRepoVERSIONFile(); fromFile != "" { return fromFile } @@ -36,6 +48,26 @@ func DisplayVersion() string { return "dev" } +// readBuildInfo is debug.ReadBuildInfo, replaceable in tests. +var readBuildInfo = debug.ReadBuildInfo + +// releaseModuleVersion returns the main module's version without the leading +// "v" when the binary was built from a tagged module (`go install +// github.com/GrayCodeAI/rho/cmd/rho@v0.3.0`), and "" for development builds: +// "(devel)", pseudo-versions (untagged commits such as @main) and versions +// with build metadata (e.g. "+dirty" from a modified checkout). +func releaseModuleVersion() string { + info, ok := readBuildInfo() + if !ok || info == nil { + return "" + } + mv := info.Main.Version + if !semver.IsValid(mv) || module.IsPseudoVersion(mv) || semver.Build(mv) != "" { + return "" + } + return strings.TrimPrefix(mv, "v") +} + func readRepoVERSIONFile() string { candidates := versionFileCandidates() for _, path := range candidates { diff --git a/cmd/version_display_test.go b/cmd/version_display_test.go index 0066d9790..622468024 100644 --- a/cmd/version_display_test.go +++ b/cmd/version_display_test.go @@ -3,6 +3,7 @@ package cmd import ( "os" "path/filepath" + "runtime/debug" "testing" rhoconfig "github.com/GrayCodeAI/rho/internal/config" @@ -21,6 +22,56 @@ func TestDisplayVersion_FromVERSIONFile(t *testing.T) { } } +func stubBuildInfo(t *testing.T, mainVersion string) { + t.Helper() + prev := readBuildInfo + readBuildInfo = func() (*debug.BuildInfo, bool) { + return &debug.BuildInfo{Main: debug.Module{Path: "github.com/GrayCodeAI/rho", Version: mainVersion}}, true + } + t.Cleanup(func() { readBuildInfo = prev }) +} + +func TestDisplayVersion_GoInstallTaggedModule(t *testing.T) { + t.Chdir(t.TempDir()) // no VERSION file nearby + SetVersion("dev") + stubBuildInfo(t, "v0.3.0") + if got := DisplayVersion(); got != "0.3.0" { + t.Fatalf("DisplayVersion() = %q, want 0.3.0 for go install ...@v0.3.0", got) + } +} + +func TestDisplayVersion_DevelopmentModuleVersionsFallBack(t *testing.T) { + for _, mv := range []string{ + "(devel)", + "v0.0.0-20260921005105-13e05ceb15e5", // go install ...@main + "v0.3.1-0.20260930120000-abcdefabcdef", // untagged commit after v0.3.0 + "v0.3.0+dirty", // modified checkout at the tag + "", + } { + t.Run(mv, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte("0.3.0\n"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(dir) + SetVersion("dev") + stubBuildInfo(t, mv) + if got := DisplayVersion(); got != "0.3.0" { + t.Fatalf("DisplayVersion() = %q, want the VERSION file value for module version %q", got, mv) + } + }) + } +} + +func TestDisplayVersion_LdflagsWinOverModuleVersion(t *testing.T) { + SetVersion("1.4.2") + t.Cleanup(func() { SetVersion("dev") }) + stubBuildInfo(t, "v0.3.0") + if got := DisplayVersion(); got != "1.4.2" { + t.Fatalf("DisplayVersion() = %q, want the ldflags version 1.4.2", got) + } +} + func TestDisplayVersion_ReleaseBuild(t *testing.T) { SetVersion("1.4.2") if got := DisplayVersion(); got != "1.4.2" { From 15a1361510820ea99e5f774bfea7d516fab32183 Mon Sep 17 00:00:00 2001 From: Lakshman Patel Date: Sun, 27 Sep 2026 04:03:31 +0530 Subject: [PATCH 16/16] =?UTF-8?q?build(go.mod):=20retract=20the=20hawk-era?= =?UTF-8?q?=20v0.1.0=E2=80=93v0.2.0=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit proxy.golang.org serves v0.1.0, v0.1.1 and v0.2.0 for github.com/GrayCodeAI/rho, but those tags were cut before the rename: their go.mod declares module github.com/GrayCodeAI/hawk and they contain no cmd/rho, so `go install .../cmd/rho@latest` fails with "does not contain package" and tooling (pkg.go.dev, dependabot) lists them as rho versions. The proxy keeps them forever, so retract the range with a rationale; retractions take effect from the first release that carries this go.mod (v0.3.0), after which @latest is v0.3.0 and the old versions are marked retracted. `go mod tidy -diff`, `go mod verify` and the replace-directive guard pass; a testaudit test keeps the range retracted and ensures VERSION is never inside it. Refs: F025 Co-Authored-By: Claude Opus 5.5 --- go.mod | 5 ++++ internal/testaudit/release_version_test.go | 28 ++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/go.mod b/go.mod index 2db566a76..388fb9360 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,11 @@ module github.com/GrayCodeAI/rho go 1.26.6 +// v0.1.0, v0.1.1 and v0.2.0 were tagged before the rename: their go.mod +// declares module github.com/GrayCodeAI/hawk and they contain no cmd/rho, so +// they cannot be used as github.com/GrayCodeAI/rho. +retract [v0.1.0, v0.2.0] + // The charmbracelet v2 modules (bubbles, bubbletea, lipgloss, glamour, huh) have // moved their module paths from github.com/charmbracelet/... to charm.land/... // but their Go import paths still use the old github.com/charmbracelet/... paths. diff --git a/internal/testaudit/release_version_test.go b/internal/testaudit/release_version_test.go index 59eb0f5b8..356af2a61 100644 --- a/internal/testaudit/release_version_test.go +++ b/internal/testaudit/release_version_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "golang.org/x/mod/modfile" "golang.org/x/mod/semver" ) @@ -36,3 +37,30 @@ func TestVersionFileIsSemVer(t *testing.T) { t.Fatalf("VERSION = %q is below 0.3.0, the first rho-named release", v) } } + +// The hawk-era tags v0.1.0, v0.1.1 and v0.2.0 are on the Go module proxy under +// this module path but declare module github.com/GrayCodeAI/hawk; go.mod must +// keep retracting them so `go list -m -versions` and tooling skip them. +func TestGoModRetractsHawkEraVersions(t *testing.T) { + f, err := modfile.Parse("go.mod", readRepoFile(t, "go.mod"), nil) + if err != nil { + t.Fatalf("parse go.mod: %v", err) + } + for _, v := range []string{"v0.1.0", "v0.1.1", "v0.2.0"} { + retracted := false + for _, r := range f.Retract { + if semver.Compare(r.Low, v) <= 0 && semver.Compare(v, r.High) <= 0 { + retracted = true + } + } + if !retracted { + t.Errorf("go.mod does not retract hawk-era %s", v) + } + } + current := "v" + repoVersion(t) + for _, r := range f.Retract { + if semver.Compare(r.Low, current) <= 0 && semver.Compare(current, r.High) <= 0 { + t.Errorf("go.mod retracts the current VERSION %s", current) + } + } +}