Skip to content
Merged
113 changes: 53 additions & 60 deletions .claude/knowledge/architecture-boundaries.md

Large diffs are not rendered by default.

33 changes: 13 additions & 20 deletions .claude/knowledge/deployment-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,20 @@ How code gets to production. Release processes, environment promotion, rollback

## Release flow

- Stable releases: push to `main` → `cd.yml` runs `release-please` → when a release PR merges, the `stable-release` job calls the **reusable** `release.yml` (`workflow_call`) with `prerelease: false`. Prereleases go through `cd.yml`'s `prepare-prerelease` → `release.yml` with `prerelease: true`.
- `release.yml` has **no `on: push: tags` trigger** — this is deliberate. Tags are minted only through the release-please approval flow (or a `create_missing_tag: true` call from the trusted `cd.yml` caller). Do not add a tag-push trigger; it would let anyone pushing a tag bypass the approval flow.
- GoReleaser (`.goreleaser.yaml`) builds only **darwin + linux on amd64/arm64** — no Windows. Archives are `tar.gz` named `codeguard_v<version>_<os>_<arch>.tar.gz` (note the literal `v` before the version).
- Stable releases: a push to `main` runs `cd.yml` → `release-please`. Merging the release PR triggers the `stable-release` job, which calls the reusable `release.yml` (`workflow_call`). Prereleases run through `cd.yml`'s `prepare-prerelease`. Manual stable runs are blocked (`guard-manual-stable`).
- **Never add an `on: push: tags` trigger to `release.yml`.** It would let a tag push bypass the release-please approval flow.
- GoReleaser builds darwin and linux on amd64/arm64 only (no Windows). Archives are named `codeguard_v<version>_<os>_<arch>.tar.gz`, with a literal `v`.
- **Keep cosign pinned to v2** (`cosign-release: v2.6.3` in `release.yml`). cosign v3 uses the bundle format, ignores `.goreleaser.yaml`'s `--output-signature`/`--output-certificate`, and fails the release. The `.sig`/`.pem` outputs are a documented contract in `docs/security.md`. Migrating means changing `.goreleaser.yaml` and the docs together.

- **cosign must stay on the v2 line** (`cosign-release: v2.6.3` pinned in `release.yml`'s cosign-installer step) until the goreleaser `signs` config migrates to the bundle format. cosign v3 — installed by default since cosign-installer v4 — enables `--new-bundle-format`, ignores the `--output-signature`/`--output-certificate` flags in `.goreleaser.yaml`, and fails with `create bundle file: open : no such file or directory` (this killed the v0.8.2 release after a Dependabot installer bump). The `.sig`/`.pem` outputs are a documented verification contract in `docs/security.md`; migrating to the single `.sigstore.json` bundle requires updating `.goreleaser.yaml` (`--bundle=${signature}`) and the docs recipe together, and un-pinning.
## npm + PyPI (packaging/)

## npm + PyPI packaging (packaging/)
- The wrappers ship the prebuilt GoReleaser binaries (`packaging/extract-binaries.sh`, `npm/build.sh`, `pypi/build_wheels.py`). See `packaging/README.md`.
- **`publish-npm`/`publish-pypi` must stay in `cd.yml`, not the reusable `release.yml`.** Both trusted publishers are registered against `cd.yml`. Inside a reusable workflow, PyPI auth matches `job_workflow_ref` (release.yml) while the PEP 740 attestation uses `workflow_ref` (cd.yml), so no single publisher satisfies both. On `invalid-publisher` or `400 Invalid attestations`, compare the OIDC claims the action prints.
- The PyPI project is **`devr-codeguard`**; the npm package is `@devr-tools/codeguard`. Both install a `codeguard` command.

- npm/PyPI ship thin wrappers around the prebuilt GoReleaser binaries — no Go toolchain at install time. `packaging/extract-binaries.sh` downloads release assets, `npm/build.sh` and `pypi/build_wheels.py` assemble artifacts. `publish-npm`/`publish-pypi` jobs in `release.yml` run for stable releases only. See `packaging/README.md`.
- **Publish jobs (`publish-npm`, `publish-pypi`) live in `cd.yml`, NOT in the reusable `release.yml`.** Both trusted publishers (npm + PyPI) are configured with workflow filename **`cd.yml`**. They run after `stable-release` (`needs: [release-please, stable-release]`, gated on `release_created == 'true'`) and download the just-built binaries from the GitHub release via `extract-binaries.sh`.
- **WHY they're in cd.yml, not release.yml (the whole saga):** OIDC trusted publishing matches a workflow filename, and PEP 740 attestations also embed it in the signing cert's Build Config URI (OID 1.3.6.1.4.1.57264.1.18). When the publish step runs inside a *reusable* workflow, the two OIDC claims split: `workflow_ref` = caller (`cd.yml`), `job_workflow_ref` = reusable file (`release.yml`). PyPI auth matches `job_workflow_ref` (release.yml) but the attestation Build Config URI is `workflow_ref` (cd.yml) — no single publisher satisfies both, so PyPI rejects with `400 Invalid attestations ... Build Config URI (...cd.yml...) does not match expected Trusted Publisher (release.yml ...)`. Running the publish jobs directly in `cd.yml` collapses both claims to `cd.yml`, so one publisher works for both registries with attestations on. (This is why szr "just worked": szr's `release.yml` is triggered directly by the tag push, so it *is* the entry workflow — both claims are `release.yml`. codeguard's `release.yml` is reusable-only, so the entry workflow is `cd.yml`.)
- Debug tip: on `invalid-publisher` or `400 Invalid attestations`, the pypi-publish action prints the actual OIDC claims — compare `workflow_ref`/`job_workflow_ref` to the configured publisher.
- Note: manual `workflow_dispatch` of `release.yml` (stable) does NOT publish to npm/PyPI — publishing only happens through the release-please → `cd.yml` path. cd.yml blocks manual stable runs (`guard-manual-stable`) anyway.
- First automated publish needs one-time manual bootstrap: `packaging/npm/bootstrap-publish.sh` (npm requires a package to exist before a trusted publisher can be added) and a PyPI *pending publisher* for project `devr-codeguard`. Both are documented step-by-step in `packaging/README.md`.
- **PyPI project name is `devr-codeguard`, NOT `codeguard`** (the plain name was already taken on PyPI). The npm scope `@devr-tools/codeguard` is unaffected. The installed command is still `codeguard` regardless — in `build_wheels.py`, `PROJECT`/`DIST` (distribution name, hyphen vs. PEP 427 escaped `devr_codeguard`) are decoupled from `BIN`/the data-script name (`codeguard`), so `pip install devr-codeguard` yields a `codeguard` command.
## Local release-readiness check

## Local release-readiness check (no goreleaser/cosign needed)

- Reproduce the release build without goreleaser: `CGO_ENABLED=0 GOOS=<darwin|linux> GOARCH=<amd64|arm64> go build -trimpath -tags=$(GRAMMAR_TAGS) -ldflags "-s -w -X github.com/devr-tools/codeguard/internal/version.Number=v<ver>" ./cmd/codeguard` — these are exactly `.goreleaser.yaml`'s flags; signing and SBOMs can only be exercised in CI.
- npm/PyPI wrappers can be built and installed from local binaries: stage them as `<staging>/<goos>_<goarch>/codeguard` (the layout `extract-binaries.sh` produces), then `npm/build.sh <ver> <staging>` and `python3 pypi/build_wheels.py <ver> <staging> <out>`; `npm pack` the launcher + one platform package (or `pip install` the matching wheel) and run `codeguard version`.
- The MCP server is `codeguard serve --mcp` (stdio); plain `serve` exits with "serve currently requires --mcp". macOS has no `timeout` binary — don't wrap commands in it in smoke scripts.
- `risk_scoring` lives under `checks.quality_rules.risk_scoring` (not `checks.risk_scoring`).
- Upgrade testing: build the previous tag from `git archive vX.Y.Z` with the same flags, write a config and a baseline with it, then scan with both binaries. A green-with-baseline repo can go red on upgrade purely from new or improved detections (no baselined finding resurfaces) — call that out in release notes.
- The full `go test -race ./...` takes ~10 minutes because `tests/checks` alone is ~8.5 min under race; pass `-timeout 60m` or it hits the 10-minute default.
- Reproduce the release binary: `CGO_ENABLED=0 GOOS=<os> GOARCH=<arch> go build -trimpath -tags=$(GRAMMAR_TAGS) -ldflags "-s -w -X github.com/devr-tools/codeguard/internal/version.Number=v<ver>" ./cmd/codeguard`. Signing and SBOMs only run in CI.
- Wrappers from local binaries: stage them as `<staging>/<goos>_<goarch>/codeguard`, then run `npm/build.sh <ver> <staging>` and `python3 pypi/build_wheels.py <ver> <staging> <out>`. Install the result and run `codeguard version`.
- MCP smoke test: use `codeguard serve --mcp`, since plain `serve` exits. macOS has no `timeout` binary.
- Upgrade test: build the previous tag from `git archive vX.Y.Z` with the same flags, write a config and baseline with it, then scan with both binaries. New detections can turn a baselined repo red; call that out in release notes.
17 changes: 11 additions & 6 deletions .claude/knowledge/local-dev-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

How to set up, run, and work with this project locally. Non-obvious dependencies, environment config, common setup issues.

- **The self-scan cache lives at `.codeguard/.codeguard/cache.json`, not `.codeguard/cache.json`.** `cache.path` resolves relative to the config directory (`config.containConfigArtifactPaths`), and `make codeguard-ci` uses `-config .codeguard/codeguard.yaml`, so the default `.codeguard/cache.json` nests. When iterating on **rule logic**, delete that file before re-running `make codeguard-ci`: cache keys cover config + file content + the release-bumped scanner-version constant, so a code-only change to a check serves stale findings from the cache and looks like your fix didn't work.
- The Makefile runs Go via `env -u GOROOT go` (Makefile:6), so `make` targets work even with the shell's stale GOROOT; direct `go` commands need `export GOROOT=/opt/homebrew/opt/go/libexec && export PATH=$GOROOT/bin:$PATH` first.
- Local `golangci-lint run` can fail from the shell's stale `GOROOT` or unwritable `~/Library/Caches/golangci-lint` even when CI is clean. Use `env -u GOROOT GOCACHE=/private/tmp/codeguard-go-cache GOLANGCI_LINT_CACHE=/private/tmp/golangci-lint-cache golangci-lint run` to reproduce CI lint locally.
- **`golangci-lint` needs a Go 1.25 GOROOT to run here — there IS a working recipe.** The installed v2.12.2 is built with go1.26.3 and cannot analyze this tree under the default go1.27.1: it panics inside `pkg/goanalysis` (`file requires newer Go version go1.27`) and emits phantom `math/rand/v2` stdlib typecheck errors on crypto-importing packages, reproducibly on unmodified HEAD. **Verified fix:** point it at the Homebrew go@1.25 toolchain — `G=$(ls -d /opt/homebrew/Cellar/go@1.25/*/libexec | head -1); GOROOT=$G PATH=$G/bin:$PATH /Users/alex/.go/bin/golangci-lint run ./...` → 0 issues. Note `make lint-strict` runs `env -u GOROOT golangci-lint`, which resolves yet another Go and panics, and the `export GOROOT=/opt/homebrew/opt/go/libexec` recipe elsewhere in this file is the 1.27 toolchain and does NOT work for lint.
- **`golangci-lint` takes a global lock** and aborts with `Error: parallel golangci-lint is running` if a second invocation overlaps — relevant when several worktrees or agents build/lint concurrently. Retry, or serialise; it is never a code problem.
- **A worktree-isolated agent cannot `git commit` here.** The `rtk`/`szr` `PreToolUse` hooks in `~/.claude/settings.json` rewrite `git commit`/`add`/`checkout`/`status`/`diff` into a `rtk git …` / `szr git …` launcher form, and the worktree-isolation guard then refuses the wrapped command; the permission classifier also denies the direct spelling. `git switch`, `git stage`, `git mv`, `git rev-parse`, `git ls-tree`, `git ls-files` pass through unrewritten, so an agent can stage and self-verify (`git ls-tree` diff of the staged tree against HEAD) but the commit itself must be made by the user or with the hooks disabled. Plan parallel-worktree work accordingly: have agents stage and write the message to a file, and commit at integration.
- **The self-scan cache is at `.codeguard/.codeguard/cache.json`**, because `cache.path` resolves relative to the config dir. Delete it before re-running `make codeguard-ci` after changing rule logic: cache keys don't cover check code, so a fix can look like it did nothing.
- The shell's `GOROOT` is stale. `make` targets unset it (`env -u GOROOT`). Direct `go` commands need `export GOROOT=/opt/homebrew/opt/go/libexec PATH=/opt/homebrew/opt/go/libexec/bin:$PATH` or `env -u GOROOT go ...`.
- **golangci-lint (CI-blocking, v2.12.2) must be pinned to the Go it was built with, or it silently under-reports.** Unpinned, it hits stdlib typecheck errors, which suppress staticcheck findings in your code, and still exits 0. `make lint-strict` is unpinned and fails here. Use:

```
env -u GOROOT GOTOOLCHAIN=go1.26.3 GOCACHE=/private/tmp/codeguard-go-cache \
GOLANGCI_LINT_CACHE=/private/tmp/golangci-lint-cache /Users/alex/.go/bin/golangci-lint run
```

Set `GOTOOLCHAIN` from `golangci-lint --version`, not from go.mod.
- **golangci-lint takes a global lock** and aborts with `parallel golangci-lint is running` when runs overlap across worktrees or agents. Serialise runs, or give each its own `GOLANGCI_LINT_CACHE`. It is never a code problem.
Loading
Loading