diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..945e00a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,34 @@ +version: 2 + +updates: + - package-ecosystem: gomod + directory: / + target-branch: dev + schedule: + interval: weekly + day: monday + time: "09:00" + timezone: America/Chicago + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(deps)" + groups: + go-runtime: + patterns: + - "*" + + - package-ecosystem: github-actions + directory: / + target-branch: dev + schedule: + interval: weekly + day: monday + time: "09:30" + timezone: America/Chicago + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(deps)" + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0c6ee68..f3fa518 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,14 +14,16 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.26.x" + GO_VERSION: "1.26.5" GOLANGCI_VERSION: "v2.12.2" GOVULNCHECK_VERSION: "v1.6.0" + GORELEASER_VERSION: "v2.17.0" + SYFT_VERSION: "v1.46.0" jobs: build-test-lint: name: build · vet · gofmt · lint · test · e2e - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 with: @@ -30,7 +32,7 @@ jobs: - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: ${{ env.GO_VERSION }} - check-latest: true + check-latest: false cache: true cache-dependency-path: go.sum @@ -62,7 +64,7 @@ jobs: - name: Vulnerability scan run: | - go install golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION} + go install "golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION}" govulncheck ./... - name: Build @@ -82,3 +84,38 @@ jobs: - name: Real two-cluster fan-out test run: make e2e-kind KIND="$(go env GOPATH)/bin/kind" + + release-snapshot: + name: reproducible archives · SPDX SBOM · Homebrew formula + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: false + cache: true + cache-dependency-path: go.sum + + - name: Install pinned Syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + syft-version: ${{ env.SYFT_VERSION }} + + - name: Install pinned GoReleaser + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + version: ${{ env.GORELEASER_VERSION }} + install-only: true + + - name: Download and verify modules + run: | + go mod download + go mod verify + + - name: Verify release snapshot and rebuild reproducibility + run: make release-check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..57ba7bc --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,164 @@ +name: release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +env: + GO_VERSION: "1.26.5" + GORELEASER_VERSION: "v2.17.0" + SYFT_VERSION: "v1.46.0" + COSIGN_VERSION: "v3.0.6" + +jobs: + release: + name: build · sign · attest · publish + runs-on: ubuntu-24.04 + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@9f698171ed81b15d1823a05fc7211befd50c8ae0 # v6.0.3 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Validate stable tag and release ancestry + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::release tags must be stable semantic versions such as v0.1.0" + exit 1 + fi + git fetch --no-tags origin main + if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then + echo "::error::release tag must point to a commit reachable from main" + exit 1 + fi + if [ "$(git cat-file -t "$GITHUB_REF_NAME")" != "tag" ]; then + echo "::error::release tag must be annotated" + exit 1 + fi + tag_object=$(git rev-parse "$GITHUB_REF_NAME^{tag}") + if [ "$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object}" --jq '.verification.verified')" != "true" ]; then + echo "::error::release tag must carry a signature verified by GitHub" + exit 1 + fi + echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + + - uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 + with: + go-version: ${{ env.GO_VERSION }} + check-latest: false + cache: true + cache-dependency-path: go.sum + + - name: Install pinned Syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + syft-version: ${{ env.SYFT_VERSION }} + + - name: Install pinned Cosign + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + with: + cosign-release: ${{ env.COSIGN_VERSION }} + + - name: Download and verify modules + run: | + go mod download + go mod verify + + - name: Build signed draft release + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 + with: + version: ${{ env.GORELEASER_VERSION }} + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify release assets and render Homebrew formula + run: | + go run ./tools/releasecheck verify --dist dist + go run ./tools/releasecheck formula \ + --dist dist \ + --tag "$GITHUB_REF_NAME" \ + --output dist/sith.rb + + - name: Sign Homebrew formula + run: cosign sign-blob --yes --bundle=dist/sith.rb.sigstore.json dist/sith.rb + + - name: Generate SLSA build provenance + id: provenance + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-checksums: dist/checksums.txt + + - name: Attest darwin amd64 SBOM + id: sbom_darwin_amd64 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: dist/sith_${{ env.VERSION }}_darwin_amd64.tar.gz + sbom-path: dist/sith_${{ env.VERSION }}_darwin_amd64.tar.gz.spdx.json + + - name: Attest darwin arm64 SBOM + id: sbom_darwin_arm64 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: dist/sith_${{ env.VERSION }}_darwin_arm64.tar.gz + sbom-path: dist/sith_${{ env.VERSION }}_darwin_arm64.tar.gz.spdx.json + + - name: Attest linux amd64 SBOM + id: sbom_linux_amd64 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: dist/sith_${{ env.VERSION }}_linux_amd64.tar.gz + sbom-path: dist/sith_${{ env.VERSION }}_linux_amd64.tar.gz.spdx.json + + - name: Attest linux arm64 SBOM + id: sbom_linux_arm64 + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-path: dist/sith_${{ env.VERSION }}_linux_arm64.tar.gz + sbom-path: dist/sith_${{ env.VERSION }}_linux_arm64.tar.gz.spdx.json + + - name: Attach attestations and Homebrew formula + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PROVENANCE_BUNDLE: ${{ steps.provenance.outputs.bundle-path }} + SBOM_DARWIN_AMD64_BUNDLE: ${{ steps.sbom_darwin_amd64.outputs.bundle-path }} + SBOM_DARWIN_ARM64_BUNDLE: ${{ steps.sbom_darwin_arm64.outputs.bundle-path }} + SBOM_LINUX_AMD64_BUNDLE: ${{ steps.sbom_linux_amd64.outputs.bundle-path }} + SBOM_LINUX_ARM64_BUNDLE: ${{ steps.sbom_linux_arm64.outputs.bundle-path }} + run: | + set -euo pipefail + install -m 0644 "$PROVENANCE_BUNDLE" "dist/sith_${VERSION}_provenance.sigstore.json" + install -m 0644 "$SBOM_DARWIN_AMD64_BUNDLE" "dist/sith_${VERSION}_darwin_amd64.sbom.sigstore.json" + install -m 0644 "$SBOM_DARWIN_ARM64_BUNDLE" "dist/sith_${VERSION}_darwin_arm64.sbom.sigstore.json" + install -m 0644 "$SBOM_LINUX_AMD64_BUNDLE" "dist/sith_${VERSION}_linux_amd64.sbom.sigstore.json" + install -m 0644 "$SBOM_LINUX_ARM64_BUNDLE" "dist/sith_${VERSION}_linux_arm64.sbom.sigstore.json" + gh release upload "$GITHUB_REF_NAME" \ + dist/sith.rb \ + dist/sith.rb.sigstore.json \ + "dist/sith_${VERSION}_provenance.sigstore.json" \ + "dist/sith_${VERSION}_darwin_amd64.sbom.sigstore.json" \ + "dist/sith_${VERSION}_darwin_arm64.sbom.sigstore.json" \ + "dist/sith_${VERSION}_linux_amd64.sbom.sigstore.json" \ + "dist/sith_${VERSION}_linux_arm64.sbom.sigstore.json" \ + --clobber + + - name: Publish completed release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release edit "$GITHUB_REF_NAME" --draft=false --latest diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..eb0ed2f --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,135 @@ +version: 2 + +project_name: sith + +builds: + - id: sith + main: ./cmd/sith + binary: sith + env: + - CGO_ENABLED=0 + - GOPROXY=off + goos: + - darwin + - linux + goarch: + - amd64 + - arm64 + flags: + - -trimpath + - -buildvcs=false + - -mod=readonly + ldflags: + - >- + -s -w + -X github.com/ArdurAI/sith/internal/buildinfo.Version={{ .Version }} + -X github.com/ArdurAI/sith/internal/buildinfo.Commit={{ .Commit }} + -X github.com/ArdurAI/sith/internal/buildinfo.Date={{ .CommitDate }} + mod_timestamp: "{{ .CommitTimestamp }}" + +archives: + - id: sith + ids: + - sith + formats: + - tar.gz + name_template: "sith_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + builds_info: + owner: root + group: root + mode: 0755 + mtime: "{{ .CommitDate }}" + files: + - src: LICENSE + info: + owner: root + group: root + mode: 0644 + mtime: "{{ .CommitDate }}" + - src: README.md + info: + owner: root + group: root + mode: 0644 + mtime: "{{ .CommitDate }}" + +sboms: + - id: archives + artifacts: archive + ids: + - sith + documents: + - "{{ .ArtifactName }}.spdx.json" + cmd: syft + args: + - "${artifact}" + - --output + - "spdx-json=${document}" + - --enrich + - all + disable: false + +checksum: + name_template: checksums.txt + algorithm: sha256 + +signs: + - id: keyless-archives + cmd: cosign + artifacts: archive + signature: "${artifact}.sigstore.json" + args: + - sign-blob + - "--bundle=${signature}" + - "${artifact}" + - --yes + output: true + - id: keyless-sboms + cmd: cosign + artifacts: sbom + signature: "${artifact}.sigstore.json" + args: + - sign-blob + - "--bundle=${signature}" + - "${artifact}" + - --yes + output: true + - id: keyless-checksum + cmd: cosign + artifacts: checksum + signature: "${artifact}.sigstore.json" + args: + - sign-blob + - "--bundle=${signature}" + - "${artifact}" + - --yes + output: true + +release: + github: + owner: ArdurAI + name: sith + draft: true + replace_existing_draft: true + replace_existing_artifacts: true + prerelease: auto + make_latest: true + mode: replace + header: | + Sith is a local-first, account-free Kubernetes fleet tool from ArdurAI. + + Every archive in this release is reproducibly built from the tagged commit and is accompanied + by an SPDX SBOM, a keyless Sigstore bundle, and GitHub-hosted SLSA provenance. + + footer: | + Verify checksums, signatures, and provenance before installation; see `docs/RELEASE.md`. + +changelog: + use: git + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" + - "Merge pull request" diff --git a/Makefile b/Makefile index f78c818..66c058f 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ BIN_DIR := bin GOLANGCI ?= golangci-lint GOVULNCHECK ?= govulncheck KIND ?= kind +GORELEASER ?= goreleaser KIND_NODE_IMAGE ?= kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5 @@ -20,7 +21,7 @@ LDFLAGS := -s -w \ -X $(PKG)/internal/buildinfo.Commit=$(COMMIT) \ -X $(PKG)/internal/buildinfo.Date=$(DATE) -.PHONY: all build test perf e2e e2e-kind lint vuln fmt fmt-check vet tidy clean run ci help +.PHONY: all build test perf e2e e2e-kind lint vuln fmt fmt-check vet tidy clean run ci release-check help all: build @@ -69,6 +70,22 @@ run: build ## Build then run sith version ci: fmt-check vet lint vuln test perf e2e build ## Run the full CI gate locally +release-check: ## Build and verify the reproducible multi-platform release snapshot twice + @command -v "$(GORELEASER)" >/dev/null || { echo "goreleaser is required" >&2; exit 1; } + @command -v syft >/dev/null || { echo "syft is required" >&2; exit 1; } + @tmp="$$(mktemp -d)"; trap 'rm -rf "$$tmp"' EXIT; \ + go mod download; \ + go mod verify; \ + "$(GORELEASER)" check .goreleaser.yaml; \ + "$(GORELEASER)" release --snapshot --clean --skip=sign; \ + go run ./tools/releasecheck verify --dist dist; \ + go run ./tools/releasecheck digests --dist dist > "$$tmp/first.sha256"; \ + "$(GORELEASER)" release --snapshot --clean --skip=sign; \ + go run ./tools/releasecheck verify --dist dist; \ + go run ./tools/releasecheck formula --dist dist --output dist/sith.rb; \ + go run ./tools/releasecheck digests --dist dist > "$$tmp/second.sha256"; \ + diff -u "$$tmp/first.sha256" "$$tmp/second.sha256" + help: ## List targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ awk 'BEGIN{FS=":.*?## "}{printf " \033[36m%-10s\033[0m %s\n",$$1,$$2}' diff --git a/README.md b/README.md index 1c74f2f..2dca41b 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Sith -**Status: Slice 7 local advisory Investigation Brain.** The CLI, TUI, browser IDE, optional MCP server, -and deterministic advisory brain +**Status: Phase-L client plus release supply-chain gate.** The CLI, TUI, browser IDE, optional MCP server, +deterministic advisory brain, and reproducible multi-platform release pipeline discover every context resolved by client-go, hydrate one local in-memory fleet cache through per-context watches, serve coverage-honest fleet search/correlation, and provide explicit-context logs, exec, port-forward, describe, and YAML view/edit. Local mode requires no account, emits no @@ -14,6 +14,21 @@ It is designed to aggregate every kubeconfig context without an account, telemet data leaving the machine. The same source-abstract fleet model will later power an optional governed hub. +## Install + +On macOS or Linux with Homebrew: + +```bash +brew tap ArdurAI/tap +brew install sith +sith version +``` + +Release archives are also available for `darwin/amd64`, `darwin/arm64`, `linux/amd64`, and +`linux/arm64`. Every archive has a checksum, an SPDX SBOM, a keyless Sigstore bundle, SLSA build +provenance, and a platform-specific SBOM attestation. Verify those materials before installing; +the exact online and offline commands are in [`docs/RELEASE.md`](docs/RELEASE.md). + ## Build and run Sith requires a supported Go 1.26 toolchain. @@ -137,6 +152,13 @@ Run the full local quality gate with golangci-lint v2.12.2 and govulncheck v1.6. make ci ``` +Release changes additionally require GoReleaser v2.17.0 and Syft v1.46.0. This gate builds all +four archives twice and refuses the change if their SHA-256 digests differ: + +```bash +make release-check +``` + The gate also compiles the binary under a functional HTTP/HTTPS egress sentinel and exercises local commands, deterministic investigation, plus the running web UI and MCP server with an official SDK client. A source boundary exact-allowlists production network, filesystem-write, and subprocess imports, confines diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..42739fb --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,106 @@ +# Sith release and verification guide + +Sith releases are immutable, tag-driven builds from `main`. The release job creates a draft, +builds four archives with GoReleaser, emits an SPDX 2.3 SBOM for each archive with Syft, signs the +archives, SBOMs, and checksum manifest with keyless Cosign, and creates GitHub SLSA provenance plus +one SBOM attestation per platform. The draft becomes public only after every step succeeds. + +The workflow follows the primary guidance for [GitHub artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations), +[GoReleaser reproducible Go builds](https://goreleaser.com/customization/builds/builders/go/#reproducible-builds), +[GoReleaser SBOM generation](https://goreleaser.com/customization/sbom/), and +[Cosign blob bundles](https://docs.sigstore.dev/cosign/signing/signing_with_blobs/). + +## Install with Homebrew + +```bash +brew tap ArdurAI/tap +brew install sith +sith version --output json +``` + +The tap formula is generated from the release checksum manifest; it does not carry hand-entered +URLs or hashes. The release workflow signs the formula itself. The tap's own repository automation +verifies that signature and the signed checksum manifest before importing the formula, so the Sith +release token never needs cross-repository write access. + +## Verify a release + +Set the release and platform, then download its assets: + +```bash +tag=v0.1.0 +version=${tag#v} +platform=darwin_arm64 +gh release download "$tag" --repo ArdurAI/sith --dir "sith-$version" +cd "sith-$version" +shasum -a 256 -c checksums.txt +``` + +Verify the archive's keyless signature. The certificate identity binds the signature to the exact +Sith release workflow and tag; the issuer check binds it to GitHub Actions OIDC: + +```bash +archive="sith_${version}_${platform}.tar.gz" +cosign verify-blob \ + --bundle "${archive}.sigstore.json" \ + --certificate-identity "https://github.com/ArdurAI/sith/.github/workflows/release.yml@refs/tags/${tag}" \ + --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \ + "$archive" +``` + +Verify SLSA provenance online against GitHub's attestation store: + +```bash +gh attestation verify "$archive" \ + --repo ArdurAI/sith \ + --signer-workflow ArdurAI/sith/.github/workflows/release.yml +``` + +Or verify with the release-attached provenance bundle: + +```bash +gh attestation verify "$archive" \ + --repo ArdurAI/sith \ + --signer-workflow ArdurAI/sith/.github/workflows/release.yml \ + --bundle "sith_${version}_provenance.sigstore.json" +``` + +Verify that the platform SBOM is cryptographically tied to the same archive: + +```bash +gh attestation verify "$archive" \ + --repo ArdurAI/sith \ + --signer-workflow ArdurAI/sith/.github/workflows/release.yml \ + --predicate-type https://spdx.dev/Document \ + --bundle "sith_${version}_${platform}.sbom.sigstore.json" +``` + +These checks establish producer identity, artifact integrity, build provenance, and the SBOM +binding. They do not prove that every dependency is vulnerability-free; consumers must still +evaluate the attached SBOM against their own policy and current advisory data. + +## Maintainer release procedure + +1. Merge the feature PR into `dev`, ensure the full CI and release-snapshot jobs are green, then + merge a reviewed `dev` to `main` release PR. +2. From an up-to-date `main`, run `make ci` and `make release-check`. The latter compares archive + SHA-256 digests across two complete builds; SBOM creation timestamps and Sigstore signatures are + intentionally not expected to be byte-for-byte reproducible. +3. Create an annotated, signed stable-semver tag on the release commit and push only that tag. +4. Watch the `release` workflow. A failure leaves a draft, not a partially trusted public release. + A rerun replaces the incomplete draft and its assets. +5. Verify one archive with the commands above, dispatch the `ArdurAI/homebrew-tap` sync workflow, + and prove a clean `brew install sith && sith version` before announcing the release. +6. Check Dependabot, code-scanning, and secret-scanning alerts after publication. + +Published versions are immutable. A bad public release is corrected with a new patch version; do +not replace its tag or silently rewrite assets. The release job uses only short-lived GitHub OIDC +credentials for Fulcio/Rekor and GitHub attestations. No long-lived signing key or cross-repository +Homebrew token is stored in Sith. + +## Cost and operational notes + +The incremental cost is GitHub-hosted runner time for four cross-builds, two snapshot builds on +each PR, Syft scans, and five attestations on a tag. Fulcio and Rekor use Sigstore's public-good +service for this public repository. Releases create no runtime cloud service, NAT egress path, or +persistent signing infrastructure. diff --git a/docs/adr/0009-release-supply-chain.md b/docs/adr/0009-release-supply-chain.md new file mode 100644 index 0000000..e27f2e2 --- /dev/null +++ b/docs/adr/0009-release-supply-chain.md @@ -0,0 +1,73 @@ +# ADR 0009: Reproducible and identity-bound release supply chain + +**Status:** Accepted +**Date:** 2026-07-11 +**Decision owners:** E9 / Slice P (#27) + +## Context + +Phase L promises `brew install sith` while the product itself is local-first and credential +sensitive. A release archive is therefore part of Sith's trust boundary: an attacker who replaces +the binary, its checksum, or its Homebrew hash bypasses every runtime guardrail before Sith starts. +E9 requires multi-platform builds, SBOMs, keyless signing, in-toto attestations, and SLSA Build +Level 2 from the first tag. + +Release jobs also fail in the middle. Publishing archives before their signatures and provenance +exist creates a public interval with incomplete trust material. Giving the Sith repository a broad +personal token to update another repository would solve Homebrew automation by introducing a +long-lived cross-repository credential. + +## Decision + +1. GoReleaser v2.17.0 produces `darwin/amd64`, `darwin/arm64`, `linux/amd64`, and `linux/arm64` + archives with Go 1.26.5. Builds disable CGO and VCS stamping, trim paths, consume a verified + module cache with `GOPROXY=off` and `-mod=readonly`, use the commit time for embedded build + metadata and file modification times, and normalize archive modes and ownership. +2. CI performs two complete snapshot builds and compares archive SHA-256 digests. It separately + verifies checksum coverage, exact archive shape, native `sith version` metadata, and SPDX 2.3 + documents. SBOM timestamps and transparency-log signatures are not called reproducible. +3. Syft v1.46.0 creates one SPDX SBOM per archive. The checksum manifest covers both archives and + SBOMs. Cosign v3.0.6 signs every archive, SBOM, and the checksum manifest with GitHub's short-lived + OIDC identity and emits self-contained Sigstore bundles. +4. `actions/attest` v4 creates one SLSA provenance statement over the checksum manifest's subjects + and one SPDX predicate binding for each archive/SBOM pair. Action dependencies are pinned to + immutable commit SHAs. +5. GoReleaser publishes to a replaceable draft. Only after formula generation and all attestations + are attached does the workflow make the release public. Stable tags must be annotated and point + to a commit reachable from `main`. +6. The Homebrew formula is rendered by repository-owned, unit-tested Go code from the same checksum + manifest and receives its own keyless signature. The tap verifies the formula and checksum + identities, then pulls it using its own scoped automation; Sith stores no personal or + cross-repository token. + +## Consequences + +- A consumer can verify checksums, the release-workflow identity, Rekor inclusion, SLSA provenance, + and the archive-specific SBOM binding online or from attached bundles. +- A compromised ordinary feature workflow cannot mint the expected release identity because + verification binds the certificate to `.github/workflows/release.yml` at a stable tag. +- GitHub-hosted runners and the public Sigstore/GitHub attestation services remain trusted build + dependencies. SLSA L2 provides hosted, authenticated provenance; it is not a hermetic or + independently reproduced build. +- Pull requests pay for two four-target builds and Syft scans. Tag releases pay for keyless signing + and five attestations. There is no persistent signing service or runtime cloud cost. +- A failed tag run leaves a draft that can be replaced. A published bad release requires a new + patch version; existing tags and assets are immutable. +- GoReleaser, Syft, Cosign, and action pins require explicit update PRs. Dependabot and regular + security review must cover those pins as part of the release boundary. + +## Alternatives considered + +- **Long-lived Cosign key:** rejected because key storage, rotation, and compromise recovery add a + high-value secret where GitHub OIDC and Fulcio provide an ephemeral identity. +- **Sign only `checksums.txt`:** rejected because direct archive and SBOM bundles make offline + verification simpler and reduce trust-chain ambiguity. +- **Publish first, attest later:** rejected because a mid-run failure would expose a public release + without its promised trust material. +- **Store a personal token for the Homebrew tap:** rejected because its blast radius crosses + repositories and it cannot be least-privilege relative to a tap-owned updater. +- **Use GoReleaser's deprecated formula publisher or require a cask:** rejected. Sith keeps the + conventional `brew install sith` formula UX while owning the small deterministic renderer. +- **Claim reproducible SBOMs and signatures:** rejected. Syft creation metadata, OIDC certificates, + and transparency-log timestamps are intentionally time-varying; only the archives are compared + byte for byte. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4313bce..0e7221c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -18,6 +18,7 @@ decision rests on an external fact, that fact is web-verified and cited (see als | [0006](0006-credential-key-custody.md) | Credential & key custody | Proposed | | [0007](0007-local-mcp-transport-auth.md) | Local MCP transport, scope, and authentication | Accepted | | [0008](0008-deterministic-advisory-brain.md) | Deterministic local advisory brain and evidence contract | Accepted | +| [0009](0009-release-supply-chain.md) | Reproducible and identity-bound release supply chain | Accepted | Planning ADRs remain **Proposed** until their implementation lane accepts or rejects them. Implementation-specific ADRs may be **Accepted** when the corresponding shipped slice provides diff --git a/sessions/2026-07-11-slice-p-release-supply-chain.md b/sessions/2026-07-11-slice-p-release-supply-chain.md new file mode 100644 index 0000000..da1fdee --- /dev/null +++ b/sessions/2026-07-11-slice-p-release-supply-chain.md @@ -0,0 +1,23 @@ +# Session — 2026-07-11 — slice-p-release-supply-chain + +**Builder:** Gnani Rahul · **Model/effort:** GPT-5, max · **Branch:** gnanirahulnutakki/feat/release-supply-chain +**Slice(s):** Slice P / E9 #27 Phase-L subset · **Status:** ready-for-PR + +--- + +[G] Goal: Ship the Phase-L release gate from E9 #27: reproducible multi-platform archives, attached SPDX SBOMs, keyless Sigstore signatures, SLSA L2 provenance, release metadata, and a working Homebrew install path. +[S] Scope: GoReleaser configuration, release and verification workflows, release documentation, deterministic Homebrew packaging, and supply-chain tests. Hub Helm charts, OCM addon packaging, deployment profiles, and air-gap installation remain later E9 slices; issue #27 stays open. +[A] Action: Re-read the authoritative build sequence, conventions, roadmap, and live issue #27. Verified that the repository has no tags, releases, release workflow, SBOM/signing/provenance configuration, or existing ArdurAI Homebrew tap. Selected pinned GoReleaser, Syft, Cosign, and GitHub artifact-attestation tooling from their primary release sources. +[T] Test: Baseline branch is clean at origin/dev commit 7a9433904956c39c7c2c56feaa220befa64546da; prior Slice 7 CI, release, two-cluster kind, and GitHub security checks are green. +[A] Action: Added GoReleaser 2.17.0 configuration for CGO-free darwin/linux amd64/arm64 builds with a pinned Go 1.26.5 toolchain, verified offline module cache, readonly modules, path/VCS trimming, commit-derived metadata and timestamps, and normalized archive ownership and modes. Syft 1.46.0 emits one SPDX 2.3 SBOM per archive; Cosign 3.0.6 keylessly signs every archive, SBOM, checksum manifest, and generated Homebrew formula. +[T] Test: The release snapshot gate validates checksums, exact archive contents, native binary version/commit/date/platform, non-empty Syft SPDX identity, and formula target/hash completeness. Two full pinned-tool builds produced byte-identical archive SHA-256 manifests with `GOPROXY=off`; Ruby syntax, Homebrew strict audit, and Homebrew style checks passed for the generated formula. +[A] Action: Added a tag-only release workflow pinned entirely to immutable action SHAs. It accepts only GitHub-verified signed annotated stable-semver tags reachable from `main`, publishes to a replaceable draft, creates SLSA provenance plus four archive-specific SPDX attestations, attaches all bundles, and makes the release public only after every trust artifact exists. Added static fail-closed workflow-policy tests and Dependabot coverage for Go modules and GitHub Actions. +[T] Test: GoReleaser configuration validation and actionlint 1.7.12 are green. The privacy boundary initially rejected process/filesystem-capable release code under production `internal/`; moving it under `tools/internal/` restored the invariant and targeted privacy/race/lint tests pass. +[A] Action: Added ADR 0009, the release/verification runbook, and install documentation. Created `ArdurAI/homebrew-tap` with a signed seed commit (`f7084518e9c6268f7266206ea17020754cb4fc67`), scheduled/manual formula sync that verifies both keyless identities and signed checksum bindings, and macOS Homebrew audit/install/test CI. The tap uses only its own scoped token; Sith has no cross-repository credential. +[T] Test: Full `make ci` is green with zero lint findings and no govulncheck vulnerabilities. The digest-pinned real two-cluster kind gate passed under `-race` in 90.660s; Docker cleanup reclaimed 913.1 MB. Homebrew tap CI run 29167709347 and no-release sync smoke run 29167717172 both passed. GitHub Dependabot, code-scanning, and secret-scanning queues are each zero open. +[C] Checkpoint #1: signed Homebrew tap bootstrap `f7084518e9c6268f7266206ea17020754cb4fc67` — next: publish the Sith release pipeline. +[C] Checkpoint #2: reproducible signed release and verification pipeline — next: PR, green remote gates, dev/main release integration, first signed tag, and real Homebrew install proof. + +--- + +**Session close:** local and tap gates green; ready for PR · **Open questions touched:** none diff --git a/tools/internal/releasepack/policy_test.go b/tools/internal/releasepack/policy_test.go new file mode 100644 index 0000000..ad36760 --- /dev/null +++ b/tools/internal/releasepack/policy_test.go @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: Apache-2.0 + +package releasepack + +import ( + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "testing" +) + +func TestReleasePolicyIsFailClosed(t *testing.T) { + t.Parallel() + root := repositoryRoot(t) + release := readRepositoryFile(t, root, ".github/workflows/release.yml") + config := readRepositoryFile(t, root, ".goreleaser.yaml") + + for _, want := range []string{ + "id-token: write", + "attestations: write", + "persist-credentials: false", + "release tag must point to a commit reachable from main", + "release tag must be annotated", + "release tag must carry a signature verified by GitHub", + "cosign sign-blob --yes --bundle=dist/sith.rb.sigstore.json dist/sith.rb", + `gh release edit "$GITHUB_REF_NAME" --draft=false --latest`, + } { + if !strings.Contains(release, want) { + t.Errorf("release workflow does not enforce %q", want) + } + } + if count := strings.Count(release, "uses: actions/attest@"); count != 5 { + t.Errorf("release workflow has %d attestation steps, want one provenance and four SBOM attestations", count) + } + for _, forbidden := range []string{"pull_request_target:", "workflow_run:", "HOMEBREW_TAP_TOKEN", "PERSONAL_AUTH_TOKEN"} { + if strings.Contains(release, forbidden) { + t.Errorf("release workflow contains forbidden trust expansion %q", forbidden) + } + } + + for _, want := range []string{ + "-buildvcs=false", + "-mod=readonly", + "GOPROXY=off", + "Date={{ .CommitDate }}", + `mod_timestamp: "{{ .CommitTimestamp }}"`, + "artifacts: archive", + "artifacts: sbom", + "artifacts: checksum", + "draft: true", + "replace_existing_draft: true", + } { + if !strings.Contains(config, want) { + t.Errorf("GoReleaser configuration does not enforce %q", want) + } + } +} + +func TestWorkflowActionsUseImmutableRefs(t *testing.T) { + t.Parallel() + root := repositoryRoot(t) + immutable := regexp.MustCompile(`^[0-9a-f]{40}$`) + use := regexp.MustCompile(`(?m)^\s*-?\s*uses:\s*[^@\s]+@([^\s#]+)`) + for _, name := range []string{".github/workflows/ci.yml", ".github/workflows/release.yml"} { + contents := readRepositoryFile(t, root, name) + matches := use.FindAllStringSubmatch(contents, -1) + if len(matches) == 0 { + t.Fatalf("%s contains no action references", name) + } + for _, match := range matches { + if !immutable.MatchString(match[1]) { + t.Errorf("%s uses mutable action ref %q", name, match[1]) + } + } + } +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("cannot locate release policy test") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "../../..")) +} + +func readRepositoryFile(t *testing.T, root, name string) string { + t.Helper() + contents, err := os.ReadFile(filepath.Join(root, name)) // #nosec G304 -- test paths are fixed relative to this source file. + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return string(contents) +} diff --git a/tools/internal/releasepack/releasepack.go b/tools/internal/releasepack/releasepack.go new file mode 100644 index 0000000..6e8d87b --- /dev/null +++ b/tools/internal/releasepack/releasepack.go @@ -0,0 +1,426 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package releasepack validates release artifacts and renders the Homebrew formula. +package releasepack + +import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "time" +) + +var ( + checksumLine = regexp.MustCompile(`^([0-9a-f]{64}) [ *]([^/\\]+)$`) + versionValue = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$`) +) + +var targets = []struct { + os string + arch string +}{ + {os: "darwin", arch: "amd64"}, + {os: "darwin", arch: "arm64"}, + {os: "linux", arch: "amd64"}, + {os: "linux", arch: "arm64"}, +} + +// Metadata is the stable subset of GoReleaser metadata used by the verifier. +type Metadata struct { + Version string `json:"version"` + Commit string `json:"commit"` +} + +// ParseChecksums parses a sha256sum-compatible manifest and rejects ambiguous paths. +func ParseChecksums(reader io.Reader) (map[string]string, error) { + contents, err := io.ReadAll(io.LimitReader(reader, 1<<20)) + if err != nil { + return nil, fmt.Errorf("read checksum manifest: %w", err) + } + + checksums := make(map[string]string) + for number, line := range strings.Split(strings.TrimSpace(string(contents)), "\n") { + match := checksumLine.FindStringSubmatch(strings.TrimSuffix(line, "\r")) + if match == nil { + return nil, fmt.Errorf("checksum line %d is not sha256sum format", number+1) + } + name := match[2] + if _, exists := checksums[name]; exists { + return nil, fmt.Errorf("duplicate checksum entry %q", name) + } + checksums[name] = match[1] + } + if len(checksums) == 0 { + return nil, errors.New("checksum manifest is empty") + } + return checksums, nil +} + +// ArchiveDigests returns the deterministic digest manifest used for rebuild comparison. +func ArchiveDigests(dist string) ([]byte, error) { + metadata, checksums, err := loadDistribution(dist) + if err != nil { + return nil, err + } + + lines := make([]string, 0, len(targets)) + for _, target := range targets { + name := archiveName(metadata.Version, target.os, target.arch) + digest, ok := checksums[name] + if !ok { + return nil, fmt.Errorf("checksum manifest is missing %s", name) + } + lines = append(lines, digest+" "+name) + } + sort.Strings(lines) + return []byte(strings.Join(lines, "\n") + "\n"), nil +} + +// RenderHomebrewFormula renders a formula whose URLs and hashes are bound to one release. +func RenderHomebrewFormula(version, tag string, checksums map[string]string) ([]byte, error) { + if !versionValue.MatchString(version) { + return nil, fmt.Errorf("invalid release version %q", version) + } + if tag != "v"+version { + return nil, fmt.Errorf("tag %q does not match version %q", tag, version) + } + + digest := func(goos, goarch string) (string, error) { + name := archiveName(version, goos, goarch) + value, ok := checksums[name] + if !ok { + return "", fmt.Errorf("checksum manifest is missing %s", name) + } + return value, nil + } + + darwinAMD64, err := digest("darwin", "amd64") + if err != nil { + return nil, err + } + darwinARM64, err := digest("darwin", "arm64") + if err != nil { + return nil, err + } + linuxAMD64, err := digest("linux", "amd64") + if err != nil { + return nil, err + } + linuxARM64, err := digest("linux", "arm64") + if err != nil { + return nil, err + } + + formula := fmt.Sprintf(`class Sith < Formula + desc "Local-first, account-free Kubernetes fleet tool" + homepage "https://github.com/ArdurAI/sith" + version %q + license "Apache-2.0" + + on_macos do + on_intel do + url %q + sha256 %q + end + on_arm do + url %q + sha256 %q + end + end + + on_linux do + on_intel do + url %q + sha256 %q + end + on_arm do + url %q + sha256 %q + end + end + + def install + bin.install "sith" + end + + test do + output = shell_output("#{bin}/sith version --output json") + assert_match %q, output + end +end +`, version, + releaseURL(tag, archiveName(version, "darwin", "amd64")), darwinAMD64, + releaseURL(tag, archiveName(version, "darwin", "arm64")), darwinARM64, + releaseURL(tag, archiveName(version, "linux", "amd64")), linuxAMD64, + releaseURL(tag, archiveName(version, "linux", "arm64")), linuxARM64, + `"version":"`+version+`"`) + return []byte(formula), nil +} + +// VerifyDistribution verifies checksums, archive shape, SBOMs, and the native binary metadata. +func VerifyDistribution(dist string) error { + metadata, checksums, err := loadDistribution(dist) + if err != nil { + return err + } + if !versionValue.MatchString(metadata.Version) { + return fmt.Errorf("metadata has invalid version %q", metadata.Version) + } + if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(metadata.Commit) { + return fmt.Errorf("metadata has invalid commit %q", metadata.Commit) + } + if len(checksums) != len(targets)*2 { + return fmt.Errorf("checksum manifest has %d entries, want %d archives and SBOMs", len(checksums), len(targets)*2) + } + + for name, want := range checksums { + path := filepath.Join(dist, name) + got, err := digestFile(path) + if err != nil { + return err + } + if got != want { + return fmt.Errorf("checksum mismatch for %s: got %s, want %s", name, got, want) + } + } + + for _, target := range targets { + archive := archiveName(metadata.Version, target.os, target.arch) + sbom := archive + ".spdx.json" + if _, ok := checksums[archive]; !ok { + return fmt.Errorf("checksum manifest is missing %s", archive) + } + if _, ok := checksums[sbom]; !ok { + return fmt.Errorf("checksum manifest is missing %s", sbom) + } + if err := verifyArchive(filepath.Join(dist, archive)); err != nil { + return fmt.Errorf("verify %s: %w", archive, err) + } + if err := verifySBOM(filepath.Join(dist, sbom), archive); err != nil { + return fmt.Errorf("verify %s: %w", sbom, err) + } + if target.os == runtime.GOOS && target.arch == runtime.GOARCH { + if err := verifyNativeBinary(filepath.Join(dist, archive), metadata); err != nil { + return fmt.Errorf("verify native binary: %w", err) + } + } + } + return nil +} + +func loadDistribution(dist string) (Metadata, map[string]string, error) { + metadataFile, err := os.Open(filepath.Join(dist, "metadata.json")) // #nosec G304 -- dist is an explicit local release directory. + if err != nil { + return Metadata{}, nil, fmt.Errorf("open release metadata: %w", err) + } + defer func() { _ = metadataFile.Close() }() + + var metadata Metadata + decoder := json.NewDecoder(io.LimitReader(metadataFile, 1<<20)) + if err := decoder.Decode(&metadata); err != nil { + return Metadata{}, nil, fmt.Errorf("decode release metadata: %w", err) + } + + checksumFile, err := os.Open(filepath.Join(dist, "checksums.txt")) // #nosec G304 -- dist is an explicit local release directory. + if err != nil { + return Metadata{}, nil, fmt.Errorf("open checksum manifest: %w", err) + } + defer func() { _ = checksumFile.Close() }() + checksums, err := ParseChecksums(checksumFile) + if err != nil { + return Metadata{}, nil, err + } + return metadata, checksums, nil +} + +func digestFile(path string) (string, error) { + file, err := os.Open(path) // #nosec G304 -- path is derived from a validated basename under the explicit dist directory. + if err != nil { + return "", fmt.Errorf("open %s: %w", filepath.Base(path), err) + } + defer func() { _ = file.Close() }() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", fmt.Errorf("hash %s: %w", filepath.Base(path), err) + } + return hex.EncodeToString(hash.Sum(nil)), nil +} + +func verifyArchive(path string) error { + file, err := os.Open(path) // #nosec G304 -- path is a required archive under the explicit dist directory. + if err != nil { + return err + } + defer func() { _ = file.Close() }() + gzipReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("open gzip stream: %w", err) + } + defer func() { _ = gzipReader.Close() }() + + wantModes := map[string]int64{"LICENSE": 0o644, "README.md": 0o644, "sith": 0o755} + seen := make(map[string]bool, len(wantModes)) + reader := tar.NewReader(gzipReader) + var timestamp time.Time + for { + header, readErr := reader.Next() + if errors.Is(readErr, io.EOF) { + break + } + if readErr != nil { + return fmt.Errorf("read tar: %w", readErr) + } + mode, ok := wantModes[header.Name] + if !ok || seen[header.Name] { + return fmt.Errorf("unexpected or duplicate archive entry %q", header.Name) + } + if header.Typeflag != tar.TypeReg || header.Mode != mode { + return fmt.Errorf("entry %s has type %d mode %#o, want regular %#o", header.Name, header.Typeflag, header.Mode, mode) + } + if header.Uid != 0 || header.Gid != 0 || header.Uname != "root" || header.Gname != "root" { + return fmt.Errorf("entry %s does not have normalized root ownership", header.Name) + } + if header.ModTime.IsZero() { + return fmt.Errorf("entry %s has zero modification time", header.Name) + } + if timestamp.IsZero() { + timestamp = header.ModTime + } else if !header.ModTime.Equal(timestamp) { + return fmt.Errorf("entry %s timestamp differs from other entries", header.Name) + } + seen[header.Name] = true + } + if len(seen) != len(wantModes) { + return fmt.Errorf("archive has %d expected entries, want %d", len(seen), len(wantModes)) + } + return nil +} + +func verifySBOM(path, archive string) error { + file, err := os.Open(path) // #nosec G304 -- path is a required SBOM under the explicit dist directory. + if err != nil { + return err + } + defer func() { _ = file.Close() }() + var document struct { + SPDXVersion string `json:"spdxVersion"` + Name string `json:"name"` + DocumentNamespace string `json:"documentNamespace"` + CreationInfo struct { + Creators []string `json:"creators"` + } `json:"creationInfo"` + Packages []json.RawMessage `json:"packages"` + } + if err := json.NewDecoder(io.LimitReader(file, 16<<20)).Decode(&document); err != nil { + return fmt.Errorf("decode SPDX JSON: %w", err) + } + if document.SPDXVersion != "SPDX-2.3" || document.Name != archive || document.DocumentNamespace == "" { + return fmt.Errorf("unexpected SPDX identity: version=%q name=%q namespace=%q", document.SPDXVersion, document.Name, document.DocumentNamespace) + } + if len(document.Packages) == 0 { + return errors.New("SPDX document contains no packages") + } + for _, creator := range document.CreationInfo.Creators { + if strings.HasPrefix(creator, "Tool: syft-") { + return nil + } + } + return errors.New("SPDX document was not created by Syft") +} + +func verifyNativeBinary(archive string, metadata Metadata) error { + directory, err := os.MkdirTemp("", "sith-release-verify-") + if err != nil { + return err + } + defer func() { _ = os.RemoveAll(directory) }() + binary := filepath.Join(directory, "sith") + if err := extractBinary(archive, binary); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + // #nosec G204 -- executing the just-built native release binary is the verifier's purpose. + output, err := exec.CommandContext(ctx, binary, "version", "--output", "json").Output() + if err != nil { + return fmt.Errorf("execute version command: %w", err) + } + var info struct { + Version string `json:"version"` + Commit string `json:"commit"` + Date string `json:"date"` + Platform string `json:"platform"` + } + if err := json.Unmarshal(output, &info); err != nil { + return fmt.Errorf("decode version output: %w", err) + } + if info.Version != metadata.Version || info.Commit != metadata.Commit || info.Date == "" || info.Date == "unknown" || info.Platform != runtime.GOOS+"/"+runtime.GOARCH { + return fmt.Errorf("unexpected version metadata: %+v", info) + } + return nil +} + +func extractBinary(archive, destination string) error { + file, err := os.Open(archive) // #nosec G304 -- archive is a required file under the explicit dist directory. + if err != nil { + return err + } + defer func() { _ = file.Close() }() + gzipReader, err := gzip.NewReader(file) + if err != nil { + return err + } + defer func() { _ = gzipReader.Close() }() + reader := tar.NewReader(gzipReader) + for { + header, readErr := reader.Next() + if errors.Is(readErr, io.EOF) { + return errors.New("archive does not contain sith") + } + if readErr != nil { + return readErr + } + if header.Name != "sith" { + continue + } + if header.Size < 1 || header.Size > 256<<20 { + return fmt.Errorf("binary size %d is outside the allowed range", header.Size) + } + // #nosec G302,G304 -- destination is a verifier-owned temp path and must be executable. + output, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700) + if err != nil { + return err + } + written, copyErr := io.CopyN(output, reader, header.Size) + closeErr := output.Close() + if copyErr != nil { + return copyErr + } + if written != header.Size { + return fmt.Errorf("extracted %d bytes, want %d", written, header.Size) + } + return closeErr + } +} + +func archiveName(version, goos, goarch string) string { + return fmt.Sprintf("sith_%s_%s_%s.tar.gz", version, goos, goarch) +} + +func releaseURL(tag, artifact string) string { + return "https://github.com/ArdurAI/sith/releases/download/" + tag + "/" + artifact +} diff --git a/tools/internal/releasepack/releasepack_test.go b/tools/internal/releasepack/releasepack_test.go new file mode 100644 index 0000000..0f3edda --- /dev/null +++ b/tools/internal/releasepack/releasepack_test.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 + +package releasepack + +import ( + "strings" + "testing" +) + +func TestParseChecksums(t *testing.T) { + t.Parallel() + digestA := strings.Repeat("a", 64) + digestB := strings.Repeat("b", 64) + checksums, err := ParseChecksums(strings.NewReader(digestA + " a.tar.gz\n" + digestB + " *b.tar.gz\n")) + if err != nil { + t.Fatalf("ParseChecksums() error = %v", err) + } + if checksums["a.tar.gz"] != digestA || checksums["b.tar.gz"] != digestB { + t.Fatalf("ParseChecksums() = %#v", checksums) + } +} + +func TestParseChecksumsRejectsUnsafeOrAmbiguousEntries(t *testing.T) { + t.Parallel() + digest := strings.Repeat("a", 64) + tests := []string{ + "", + "not-a-digest file", + digest + " ../file", + digest + " file\n" + digest + " file", + } + for _, input := range tests { + if _, err := ParseChecksums(strings.NewReader(input)); err == nil { + t.Errorf("ParseChecksums(%q) unexpectedly succeeded", input) + } + } +} + +func TestRenderHomebrewFormula(t *testing.T) { + t.Parallel() + version := "1.2.3" + checksums := make(map[string]string) + for index, target := range targets { + checksums[archiveName(version, target.os, target.arch)] = strings.Repeat(string(rune('a'+index)), 64) + } + formula, err := RenderHomebrewFormula(version, "v"+version, checksums) + if err != nil { + t.Fatalf("RenderHomebrewFormula() error = %v", err) + } + for _, want := range []string{ + `class Sith < Formula`, + `version "1.2.3"`, + `releases/download/v1.2.3/sith_1.2.3_darwin_arm64.tar.gz`, + `releases/download/v1.2.3/sith_1.2.3_linux_amd64.tar.gz`, + `assert_match "\"version\":\"1.2.3\"", output`, + } { + if !strings.Contains(string(formula), want) { + t.Errorf("formula does not contain %q:\n%s", want, formula) + } + } +} + +func TestRenderHomebrewFormulaRejectsIncompleteRelease(t *testing.T) { + t.Parallel() + checksums := map[string]string{archiveName("1.2.3", "darwin", "arm64"): strings.Repeat("a", 64)} + if _, err := RenderHomebrewFormula("1.2.3", "v1.2.3", checksums); err == nil { + t.Fatal("RenderHomebrewFormula() unexpectedly accepted missing targets") + } + if _, err := RenderHomebrewFormula("1.2.3", "v9.9.9", checksums); err == nil { + t.Fatal("RenderHomebrewFormula() unexpectedly accepted mismatched tag") + } +} diff --git a/tools/releasecheck/main.go b/tools/releasecheck/main.go new file mode 100644 index 0000000..d66f810 --- /dev/null +++ b/tools/releasecheck/main.go @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Command releasecheck verifies a distribution or renders its Homebrew formula. +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/ArdurAI/sith/tools/internal/releasepack" +) + +func main() { + if err := run(os.Args[1:], os.Stdout); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "releasecheck: %v\n", err) + os.Exit(1) + } +} + +func run(args []string, stdout io.Writer) error { + if len(args) == 0 { + return fmt.Errorf("usage: releasecheck [flags]") + } + switch args[0] { + case "verify": + flags := flag.NewFlagSet("verify", flag.ContinueOnError) + dist := flags.String("dist", "dist", "GoReleaser distribution directory") + if err := flags.Parse(args[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("verify accepts no positional arguments") + } + if err := releasepack.VerifyDistribution(*dist); err != nil { + return err + } + _, err := fmt.Fprintln(stdout, "release distribution verified") + return err + case "digests": + flags := flag.NewFlagSet("digests", flag.ContinueOnError) + dist := flags.String("dist", "dist", "GoReleaser distribution directory") + if err := flags.Parse(args[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("digests accepts no positional arguments") + } + digests, err := releasepack.ArchiveDigests(*dist) + if err != nil { + return err + } + _, err = stdout.Write(digests) + return err + case "formula": + flags := flag.NewFlagSet("formula", flag.ContinueOnError) + dist := flags.String("dist", "dist", "GoReleaser distribution directory") + tag := flags.String("tag", "", "release tag, including v prefix") + output := flags.String("output", "", "formula output path; stdout when empty") + if err := flags.Parse(args[1:]); err != nil { + return err + } + if flags.NArg() != 0 { + return fmt.Errorf("formula accepts no positional arguments") + } + metadata, checksums, err := loadInputs(*dist) + if err != nil { + return err + } + if *tag == "" { + *tag = "v" + metadata.Version + } + formula, err := releasepack.RenderHomebrewFormula(metadata.Version, *tag, checksums) + if err != nil { + return err + } + if *output == "" { + _, err = stdout.Write(formula) + return err + } + if err := os.MkdirAll(filepath.Dir(*output), 0o750); err != nil { + return fmt.Errorf("create formula directory: %w", err) + } + if err := os.WriteFile(*output, formula, 0o600); err != nil { + return fmt.Errorf("write formula: %w", err) + } + _, err = fmt.Fprintf(stdout, "wrote %s\n", *output) + return err + default: + return fmt.Errorf("unknown command %q", args[0]) + } +} + +func loadInputs(dist string) (releasepack.Metadata, map[string]string, error) { + metadataFile, err := os.Open(filepath.Join(dist, "metadata.json")) // #nosec G304 -- dist is an explicit local release directory. + if err != nil { + return releasepack.Metadata{}, nil, err + } + defer func() { _ = metadataFile.Close() }() + var metadata releasepack.Metadata + if err := json.NewDecoder(io.LimitReader(metadataFile, 1<<20)).Decode(&metadata); err != nil { + return releasepack.Metadata{}, nil, fmt.Errorf("decode release metadata: %w", err) + } + checksumFile, err := os.Open(filepath.Join(dist, "checksums.txt")) // #nosec G304 -- dist is an explicit local release directory. + if err != nil { + return releasepack.Metadata{}, nil, err + } + defer func() { _ = checksumFile.Close() }() + checksums, err := releasepack.ParseChecksums(checksumFile) + return metadata, checksums, err +}