diff --git a/.github/workflows/ci-java.yml b/.github/workflows/ci-java.yml index f8ce46a0..523e6dcd 100644 --- a/.github/workflows/ci-java.yml +++ b/.github/workflows/ci-java.yml @@ -14,16 +14,15 @@ name: Java CI on: push: branches: [main] - paths: - - 'src/**' - - 'pom.xml' - - '.github/workflows/ci-java.yml' pull_request: branches: [main] - paths: - - 'src/**' - - 'pom.xml' - - '.github/workflows/ci-java.yml' + # NOTE: no `paths:` filter. The `build` job name is a required check + # on main's branch protection, and a `paths:` filter would cause the + # check to be skipped on PRs that don't touch Java — leaving the + # required check stuck at "Waiting for status to be reported", which + # blocks merge of every non-Java PR (e.g. PR #131 phase 5 release infra). + # Java compile is ~1 minute; the cost is worth the always-on signal + # until Phase 6 cutover deletes the Java tree entirely. permissions: read-all diff --git a/.github/workflows/perf-gate.yml b/.github/workflows/perf-gate.yml new file mode 100644 index 00000000..59fdaa0f --- /dev/null +++ b/.github/workflows/perf-gate.yml @@ -0,0 +1,112 @@ +name: perf-gate + +# Performance regression gate. Runs `codeiq index` against fixture-multi-lang +# and asserts wall-clock + node-count budgets. Catches regressions like: +# - Regex pathology re-introduced (e.g. the CertificateAuthDetector +# pre-screen miss that pushed indexing from 0.1s → 42s on PSA). +# - Detector over-emission past the dedup budget. +# +# Trigger: push to main + PRs that touch go/**. Manual via workflow_dispatch. +# Failure is informational on PRs (`continue-on-error`) until the threshold +# is curated against real-world load; once stable, set strict gate. + +on: + push: + branches: [main] + paths: + - 'go/**' + - '.github/workflows/perf-gate.yml' + pull_request: + branches: [main] + paths: + - 'go/**' + - '.github/workflows/perf-gate.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + bench: + name: index perf gate (fixture-multi-lang) + runs-on: ubuntu-latest + env: + CGO_ENABLED: '1' + # Per-target budgets. Tune as the fixture grows. Current + # fixture-multi-lang sits at ~50 files; an 8 s ceiling leaves + # headroom over the observed ~0.3 s without hiding obvious + # regressions (10x cushion catches the kinds of regex pathology + # that pushed PSA from 0.1 s → 42 s mid-port). + MAX_INDEX_SECONDS: '8' + MIN_NODES: '40' + MAX_PHANTOM_DROP_RATIO: '50' + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.25.10' + cache: true + cache-dependency-path: go/go.sum + - name: Install C toolchain + run: sudo apt-get update -y && sudo apt-get install -y build-essential + - name: Build codeiq + working-directory: go + run: go build -o /tmp/codeiq ./cmd/codeiq + - name: Stage fixture (separate copy so cache writes don't dirty git) + run: cp -r go/testdata/fixture-multi-lang /tmp/fm-perf + - name: Run + measure + id: bench + run: | + set -euo pipefail + START=$(date +%s.%N) + /tmp/codeiq index /tmp/fm-perf > /tmp/perf.log 2>&1 + END=$(date +%s.%N) + ELAPSED=$(awk "BEGIN{printf \"%.3f\", $END - $START}") + + # Parse the "Files: F Nodes: N Edges: E ..." summary line. + NODES=$(awk -F'[ ]+' '/^Files:/ {print $4}' /tmp/perf.log) + EDGES=$(awk -F'[ ]+' '/^Files:/ {print $6}' /tmp/perf.log) + # Optional "Deduped: D nodes, ... Dropped: P phantom edges" + # line; absence is fine, defaults to 0. + DEDUP_NODES=$(awk -F'[ ,]+' '/^Deduped:/ {print $2}' /tmp/perf.log) + DEDUP_NODES=${DEDUP_NODES:-0} + DROPPED=$(awk -F'[ ]+' '/^Deduped:/ {for(i=1;i<=NF;i++) if($i=="Dropped:") print $(i+1)}' /tmp/perf.log) + DROPPED=${DROPPED:-0} + + echo "elapsed=$ELAPSED" >> "$GITHUB_OUTPUT" + echo "nodes=$NODES" >> "$GITHUB_OUTPUT" + echo "edges=$EDGES" >> "$GITHUB_OUTPUT" + echo "dropped=$DROPPED" >> "$GITHUB_OUTPUT" + + { + echo "## codeiq perf gate" + echo "" + echo "| metric | value | budget |" + echo "|---|---:|---:|" + echo "| wall-clock (s) | $ELAPSED | $MAX_INDEX_SECONDS |" + echo "| nodes | $NODES | >= $MIN_NODES |" + echo "| edges | $EDGES | — |" + echo "| deduped nodes | $DEDUP_NODES | — |" + echo "| dropped phantom edges | $DROPPED | ratio gated |" + } >> "$GITHUB_STEP_SUMMARY" + + cat /tmp/perf.log >> "$GITHUB_STEP_SUMMARY" + + # --- Hard gates --- + fail=0 + if awk "BEGIN{exit !($ELAPSED > $MAX_INDEX_SECONDS)}"; then + echo "::error::wall-clock $ELAPSED s exceeds budget $MAX_INDEX_SECONDS s" + fail=1 + fi + if [ "${NODES:-0}" -lt "$MIN_NODES" ]; then + echo "::error::node count $NODES below minimum $MIN_NODES" + fail=1 + fi + if [ "${EDGES:-0}" -gt 0 ] && [ "${DROPPED:-0}" -gt 0 ]; then + RATIO=$(( DROPPED * 100 / (EDGES + DROPPED) )) + if [ "$RATIO" -gt "$MAX_PHANTOM_DROP_RATIO" ]; then + echo "::error::phantom-edge drop ratio ${RATIO}% exceeds ${MAX_PHANTOM_DROP_RATIO}%" + fail=1 + fi + fi + exit $fail diff --git a/.github/workflows/release-go.yml b/.github/workflows/release-go.yml new file mode 100644 index 00000000..9052a0c1 --- /dev/null +++ b/.github/workflows/release-go.yml @@ -0,0 +1,124 @@ +name: release-go + +# Tag-triggered release pipeline for the codeiq Go binary. +# +# Trigger: push a tag matching `v*.*.*` (e.g. `git tag v1.0.0 && git push --tags`). +# Cross-OS build via per-runner matrix (CGO + native kuzudb/sqlite means +# we can't cross-compile cleanly from a single host). +# +# Phase 5 of the Java→Go port. Replaces release-java.yml (kept around +# until Phase 6 cutover for any emergency Java release). + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + tag: + description: 'Tag to release (e.g. v1.0.0). Must already exist.' + required: true + +permissions: + contents: write + id-token: write # Sigstore keyless via GitHub OIDC + packages: write + attestations: write + +jobs: + # Per-target release. Runs the same .goreleaser.yml on each runner; + # archives are merged in the publish job below. + build: + name: build (${{ matrix.os }} / ${{ matrix.goarch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - os: linux + goarch: amd64 + runner: ubuntu-latest + - os: linux + goarch: arm64 + runner: ubuntu-24.04-arm + - os: darwin + goarch: arm64 + runner: macos-14 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.25.10' + cache: true + cache-dependency-path: go/go.sum + - name: Install build deps (linux) + if: runner.os == 'Linux' + run: sudo apt-get update -y && sudo apt-get install -y build-essential + - name: Install Syft (SBOM) + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + - name: Install Cosign (signing) + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 + with: + distribution: goreleaser + version: '~> v2' + # Single-target build per runner; combined publish runs in a + # separate job that consumes all three artifact bundles. + args: build --single-target --clean --snapshot + env: + GOOS: ${{ matrix.os }} + GOARCH: ${{ matrix.goarch }} + - name: Upload binary artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: codeiq-${{ matrix.os }}-${{ matrix.goarch }} + path: dist/codeiq_*/codeiq* + retention-days: 1 + + # Combined publish: pulls the three binaries built above, packages + # them with SBOMs, signs the checksum manifest via Sigstore keyless, + # and uploads the GitHub Release. Runs on linux only. + release: + name: publish release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.25.10' + cache: true + cache-dependency-path: go/go.sum + - name: Install build deps + run: sudo apt-get update -y && sudo apt-get install -y build-essential + - name: Install Syft (SBOM) + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + - name: Install Cosign (signing) + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 + - name: Download pre-built binaries + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: codeiq-* + path: prebuilt + - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 # v7.2.1 + with: + distribution: goreleaser + version: '~> v2' + # Full release: archives + SBOMs + cosign sigs + GitHub Release + # draft + (optional) Homebrew tap. The owning org sets + # HOMEBREW_TAP_GITHUB_TOKEN to publish to homebrew-codeiq; + # forks leave it unset and the brew step skips silently. + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HOMEBREW_TAP_OWNER: RandomCodeSpace + HOMEBREW_TAP_REPO: homebrew-codeiq + HOMEBREW_TAP_GITHUB_TOKEN: ${{ secrets.HOMEBREW_TAP_GITHUB_TOKEN }} + - name: Attest release artifacts (build provenance) + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-path: 'dist/codeiq_*.tar.gz' diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 00000000..8381c236 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,151 @@ +# Goreleaser config for codeiq Go binary releases. +# +# Trigger: tag push `vX.Y.Z` → .github/workflows/release-go.yml fires +# this config. Local dry runs: `goreleaser release --snapshot --clean`. +# +# CGO is required (kuzudb + go-sqlite3 native deps), so we cross-compile +# via per-target runners — see the release workflow matrix. This file +# is consumed once per target OS. + +version: 2 +project_name: codeiq + +env: + - CGO_ENABLED=1 + - GO_VERSION=1.25.10 + +before: + hooks: + # Sanity gate. Failing here aborts the release before any binary + # leaves the runner. + - cd go && go mod download + - cd go && go test ./... -count=1 + +builds: + - id: codeiq + main: ./cmd/codeiq + dir: go + binary: codeiq + env: + - CGO_ENABLED=1 + flags: + - -trimpath + ldflags: + - -s -w + - -X 'github.com/randomcodespace/codeiq/go/internal/buildinfo.Version={{.Version}}' + - -X 'github.com/randomcodespace/codeiq/go/internal/buildinfo.Commit={{.ShortCommit}}' + - -X 'github.com/randomcodespace/codeiq/go/internal/buildinfo.Date={{.Date}}' + - -X 'github.com/randomcodespace/codeiq/go/internal/buildinfo.Dirty={{.IsGitDirty}}' + # CGO + kuzudb makes cross-arch fragile from a single host; the + # release workflow runs this config once per (OS, arch) runner. + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ignore: + # darwin/amd64 needs a darwin runner — skip when this config is + # consumed on a linux runner. The release workflow re-runs the + # darwin builds on macOS runners. + - goos: darwin + goarch: amd64 + +archives: + - id: codeiq + formats: [tar.gz] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + files: + - LICENSE* + - README.md + - CHANGELOG.md + +checksum: + name_template: 'checksums.sha256' + algorithm: sha256 + +snapshot: + version_template: '{{ incpatch .Version }}-next' + +# SBOM generation — Plan §5 SBOM signing requirement. Syft is the +# OSS-CLI choice (matches the existing security.yml stack). +sboms: + - id: codeiq-sbom + artifacts: archive + documents: + - '{{ .ArtifactName }}.sbom.spdx.json' + cmd: syft + args: + - '$artifact' + - --output + - 'spdx-json={{ .ArtifactName }}.sbom.spdx.json' + +# Cosign keyless signing of the checksum manifest. The release workflow +# supplies the OIDC token via `id-token: write`; cosign records the +# signature transparency entry in Rekor (public Sigstore log). No +# long-lived signing key required. +signs: + - id: cosign + cmd: cosign + args: + - sign-blob + - '--yes' + - '--output-signature=${signature}' + - '--output-certificate=${certificate}' + - '${artifact}' + artifacts: checksum + output: true + certificate: '${artifact}.pem' + signature: '${artifact}.sig' + +# Homebrew tap publish — opt-in via $HOMEBREW_TAP_GITHUB_TOKEN. When the +# env var is empty (forks, dry runs), the upload is skipped so the same +# .goreleaser.yml works for the owning org and downstream forks alike. +brews: + - name: codeiq + repository: + owner: '{{ envOrDefault "HOMEBREW_TAP_OWNER" "RandomCodeSpace" }}' + name: '{{ envOrDefault "HOMEBREW_TAP_REPO" "homebrew-codeiq" }}' + token: '{{ envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "" }}' + skip_upload: '{{ if eq (envOrDefault "HOMEBREW_TAP_GITHUB_TOKEN" "") "" }}true{{ else }}false{{ end }}' + commit_author: + name: codeiq-bot + email: noreply@github.com + directory: Formula + homepage: 'https://github.com/RandomCodeSpace/codeiq' + description: 'Deterministic code knowledge graph + MCP server' + license: 'Apache-2.0' + install: | + bin.install "codeiq" + test: | + assert_match "codeiq", shell_output("#{bin}/codeiq --version") + +release: + github: + owner: RandomCodeSpace + name: codeiq + draft: true + prerelease: auto + mode: replace + name_template: 'v{{ .Version }}' + header: | + ## codeiq v{{ .Version }} + + Deterministic code knowledge graph — Go single-binary release. + + Verify the download: + + # Checksum + sha256sum -c checksums.sha256 + + # Signature (Sigstore keyless) + cosign verify-blob \ + --certificate checksums.sha256.pem \ + --signature checksums.sha256.sig \ + --certificate-identity-regexp 'https://github.com/RandomCodeSpace/codeiq/.github/workflows/release-go.yml@.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums.sha256 + footer: | + --- + Built with Go {{ envOrDefault "GO_VERSION" "1.25.10" }} via Goreleaser. diff --git a/CHANGELOG.md b/CHANGELOG.md index 76dccaa9..af1bd15d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,23 @@ for that specific tag for the per-commit details. ### Added +- **Phase 5 release infrastructure for the Go binary** — + `.goreleaser.yml` + `.github/workflows/release-go.yml` cut a + multi-platform (linux/amd64, linux/arm64, darwin/arm64) release on + every `v*.*.*` tag push. Each archive ships with an SPDX SBOM + (Syft), and the `checksums.sha256` manifest is keyless-signed via + Cosign + GitHub OIDC (Sigstore Rekor transparency log). Optional + Homebrew tap publish to `RandomCodeSpace/homebrew-codeiq` — + skipped silently when the secret isn't configured, so forks can + reuse the same workflow. Build provenance attestations via + `actions/attest-build-provenance`. Runbook at + [`shared/runbooks/release-go.md`](shared/runbooks/release-go.md). +- **`.github/workflows/perf-gate.yml`** — per-PR perf-regression gate. + Runs `codeiq index` against `fixture-multi-lang` and fails the build + if wall-clock exceeds 8 s, node count drops below 40, or the + phantom-edge drop ratio crosses 50%. Catches regex-pathology + regressions like the CertificateAuthDetector pre-screen miss that + blew up PSA indexing from 0.1 s to 42 s mid-port. - **Go port (Phases 1-4 of the rewrite)** — codeiq is being ported from Java/Spring Boot to a single static Go binary on the `port/go-port` branch. PR #130. 100 detectors at 1:1 parity with the Java side; 34 MCP diff --git a/shared/runbooks/release-go.md b/shared/runbooks/release-go.md new file mode 100644 index 00000000..c8beff86 --- /dev/null +++ b/shared/runbooks/release-go.md @@ -0,0 +1,125 @@ +# Releasing the Go binary + +The Java side has its own `release.md` runbook; this one covers the Go +single-binary release that ships from Phase 5 of the port onward. + +The pipeline is **tag-triggered, fully automated, and keyless-signed**: + +1. Push a semver tag matching `v*.*.*`. +2. `.github/workflows/release-go.yml` cross-builds for linux/amd64, + linux/arm64, darwin/arm64 (CGO + native kuzudb/sqlite forces + per-target runners). +3. Goreleaser packages binaries with `LICENSE`, `README.md`, + `CHANGELOG.md` into `codeiq___.tar.gz`. +4. Syft generates an SPDX SBOM per archive. +5. Cosign keyless-signs `checksums.sha256` via GitHub OIDC (no + long-lived key on the runner; signature transparency entry lands in + the public Rekor log). +6. GitHub release is created as a **draft** with the verification + recipe embedded in the release notes header. +7. Optional Homebrew tap publish — see "Homebrew tap" below. + +## Cutting a release + +```bash +# From the repo root, on main, with a clean working tree: +git checkout main +git pull --ff-only + +# Update CHANGELOG.md [Unreleased] → [vX.Y.Z] - YYYY-MM-DD. Commit. +$EDITOR CHANGELOG.md +git add CHANGELOG.md +git commit -m "chore(release): vX.Y.Z" + +# Tag (signed) and push the tag. +git tag -s vX.Y.Z -m "vX.Y.Z" +git push origin vX.Y.Z +``` + +Within ~5 minutes: + +- `release-go` workflow finishes and creates a **draft** Release. +- Sigstore transparency log records the signature. +- (If `HOMEBREW_TAP_GITHUB_TOKEN` is configured) the `homebrew-codeiq` + tap gets a Formula bump. + +Review the draft release on GitHub — verify artifact list, checksums, +SBOM presence, release notes — then click **Publish release**. + +## Verifying a downloaded artifact + +End-users should verify both checksum AND signature: + +```bash +# Checksum +sha256sum -c checksums.sha256 + +# Signature (Sigstore keyless — no key material needed locally) +cosign verify-blob \ + --certificate checksums.sha256.pem \ + --signature checksums.sha256.sig \ + --certificate-identity-regexp 'https://github.com/RandomCodeSpace/codeiq/.github/workflows/release-go.yml@.*' \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + checksums.sha256 +``` + +A successful `cosign verify-blob` proves: + +- The binary was built by the release workflow in this repo (not a + fork, not a manually-uploaded artifact). +- The build ran on a GitHub-hosted runner under GitHub's OIDC token. +- The signature was logged to the Rekor public transparency log. + +## Homebrew tap + +The tap repo lives at `RandomCodeSpace/homebrew-codeiq` (separate from +the main repo; Homebrew's convention). + +Setup checklist (one-time, by a repo admin): + +1. Create the repo `homebrew-codeiq` under the `RandomCodeSpace` org. +2. Generate a fine-grained PAT with `Contents: write` on + `homebrew-codeiq` only. +3. Add it to `codeiq` repo secrets as `HOMEBREW_TAP_GITHUB_TOKEN`. + +After setup, every tag release updates the Formula automatically. + +If the secret is **not** set, the Homebrew step in `.goreleaser.yml` +skips silently — useful for forks and for local `goreleaser release +--snapshot` dry runs. + +## Local dry run + +To validate `.goreleaser.yml` without cutting a release: + +```bash +# Dry-run (builds + packages but doesn't publish). +goreleaser release --snapshot --clean +ls dist/ +``` + +The `--snapshot` flag forces a fake version `-next` and +disables publish steps (no GitHub upload, no signing, no Homebrew). +CGO is needed locally — `CGO_ENABLED=1` is set in +`.goreleaser.yml/env`. + +## Failure recovery + +- **Tag points at a broken commit** — delete the tag locally and + remotely (`git tag -d vX.Y.Z && git push --delete origin vX.Y.Z`), + fix, retag. The draft release will be replaced on retag because + `mode: replace` is set. +- **Signing failure (OIDC token)** — usually transient. Re-run the + workflow. The OIDC permissions in `release-go.yml` are correct; + GitHub occasionally has Sigstore connectivity issues. +- **Homebrew tap PR fails** — check the PAT scope and that the tap + repo exists. The main release still publishes; only the Formula + bump skips. + +## What this does NOT do + +- Does not push to package registries (npm, PyPI, Cargo) — codeiq is + a single binary, not a library. +- Does not run a smoke test of the published artifact post-release. + Add this once we have a canary user. +- Does not auto-bump the version. Versioning is human decision.