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/.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 8817694e0..9c2ca8196 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 @@ -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: @@ -507,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/.github/workflows/release.yml b/.github/workflows/release.yml index 1ca97e975..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,33 +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 }} - # 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 - # 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..66377da47 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 @@ -38,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/.goreleaser.yml b/.goreleaser.yml index 0a1251674..783e89c25 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -1,28 +1,24 @@ -# 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: - 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. # --------------------------------------------------------------------------- @@ -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 @@ -39,9 +39,6 @@ builds: goarch: - amd64 - arm64 - ignore: - - goos: windows - goarch: arm64 ldflags: - -s -w - -X main.Version={{.Version}} @@ -50,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 @@ -73,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. # --------------------------------------------------------------------------- @@ -86,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 @@ -119,40 +151,14 @@ changelog: - title: "Other" 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" - -# --------------------------------------------------------------------------- -# 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" +# Homebrew and npm are intentionally not configured: neither the +# GrayCodeAI/homebrew-tap repository nor the @graycodeai npm packages exist. +# 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 @@ -160,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/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/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/.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/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/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 e2550b11b..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. @@ -259,8 +270,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 diff --git a/README.md b/README.md index d36c692fe..8a90fd0de 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 @@ -522,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/VERSION b/VERSION index 8acdd82b7..0d91a54c7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.0.1 +0.3.0 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/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" { 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/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/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/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"; - }; - }); -} 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/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/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) + } +} 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() +} 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/internal/testaudit/release_version_test.go b/internal/testaudit/release_version_test.go new file mode 100644 index 000000000..356af2a61 --- /dev/null +++ b/internal/testaudit/release_version_test.go @@ -0,0 +1,66 @@ +package testaudit + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "golang.org/x/mod/modfile" + "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) + } +} + +// 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) + } + } +} 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/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/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 3b1d60924..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' }, @@ -106,19 +119,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'}`; + const dirName = `rho_${os}_${ga}_${ga === 'arm64' ? 'v8.0' : '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', @@ -162,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); }); 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 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/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." 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 diff --git a/testdata/compatibility-matrix.json b/testdata/compatibility-matrix.json index 10c778c06..54eeb48bf 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,13 +18,13 @@ "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" } }, { "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"