From 7c760a61c2f2f15c47f0439de57e0cfb88a9122c Mon Sep 17 00:00:00 2001 From: Eric Andrechek Date: Tue, 18 Aug 2026 11:16:36 -0400 Subject: [PATCH 01/18] ci(release): make every version tag-driven and fix the dev channel Release-readiness pass ahead of #149. Every component now derives its version from the tag it was built at, so cutting a release is one tag and nothing else -- no bump commit, which the main ruleset forbids pushing anyway. Release path - goreleaser: prerelease: auto (an alpha tag would have shipped as a stable GitHub Release), channel-scoped moving image tag (an rc would have taken :latest from a shipped stable), lowercase project_name, .zip on Windows, no CHANGELOG in archives - both release paths pin the generated-notes anchor to their own tag family; without it two tags on one commit yield empty notes - the SDK release step no longer fails when a release already exists - release.yml verifies its own attestations before finishing Tag naming - sdk-v* -> clients/ts/v*; Go requires clients/go/vX.Y.Z for a subdirectory module (#434), so every client shares that shape - publish-npm stamps package.json from the tag instead of validating it Fixes - #431: retire triage.yml (GitHub Models retired 2026-07-30, 410) - #475: dev versions ordered by a numeric UTC stamp, based at 0.0.1 - -X main.BuildTime never applied (non-constant initializer) - go install builds reported themselves as "dev" - dependabot could not see .github/actions/setup-env Tooling - make release-server / release-sdk-ts / release-sdk-go - CHANGELOG merged from 35 duplicate bands into 5 sections, stamped 0.1.0 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015bVwHtNakQgmnBhMcfW8pe --- .github/actions/setup-env/action.yml | 4 +- .github/board-config.env | 47 --- .github/dependabot.yml | 21 ++ .github/release.yml | 46 +++ .github/workflows/publish-npm.yml | 207 ++++++++--- .github/workflows/release.yml | 86 +++++ .github/workflows/triage.yml | 191 ---------- .gitignore | 1 - .goreleaser.yaml | 67 +++- AGENTS.md | 4 +- CHANGELOG.md | 500 +++++++++++++-------------- Makefile | 39 +++ cmd/wavehouse/main.go | 58 +++- docs/src/content/docs/development.md | 87 ++++- docs/src/content/docs/sdk/index.mdx | 2 +- scripts/ci/release-channel.sh | 68 ++++ scripts/release.sh | 172 +++++++++ 17 files changed, 1021 insertions(+), 579 deletions(-) delete mode 100644 .github/board-config.env create mode 100644 .github/release.yml delete mode 100644 .github/workflows/triage.yml create mode 100755 scripts/ci/release-channel.sh create mode 100755 scripts/release.sh diff --git a/.github/actions/setup-env/action.yml b/.github/actions/setup-env/action.yml index e7b1b76b..c43801e4 100644 --- a/.github/actions/setup-env/action.yml +++ b/.github/actions/setup-env/action.yml @@ -196,7 +196,7 @@ runs: # pnpm must install before its cache step so the store path can be # resolved from pnpm itself. - - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 if: ${{ inputs.node == 'true' }} with: version: "11.1.3" @@ -247,7 +247,7 @@ runs: restore-keys: | astro-${{ runner.os }}- - - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 if: ${{ inputs.node == 'true' }} with: node-version-file: ".nvmrc" diff --git a/.github/board-config.env b/.github/board-config.env deleted file mode 100644 index cdb7770b..00000000 --- a/.github/board-config.env +++ /dev/null @@ -1,47 +0,0 @@ -# Canonical IDs for the Wave-RF Task Board (project #7), read by -# `triage.yml` (sets the Priority field on new issues). -# -# Reviewer assignment and admin approval now live entirely in GitHub — -# no workflow or `ADMINS` list. The `main branch protection` ruleset's -# `required_reviewers` rule requires an `@Wave-RF/wavehouse-admins` team -# approval and requests the team; the team's code-review assignment -# auto-assigns + load-balances the actual reviewer. -# -# PR-side board state — placement, Status moves on merge / close — is -# handled by the project's *native* "Auto-add to project", "Item -# added", and "Pull request merged" workflows, configured in the -# project UI under project #7's Workflows tab. The orchestrator and -# its STATUS_* IDs that used to live here were removed in the same -# change that introduced this comment. -# -# Discovered via `gh project field-list 7 --owner Wave-RF` and stable -# unless the project is recreated. -# -# Workflows load this with: -# -# - name: Load board config -# run: grep -E '^[A-Z_][A-Z0-9_]*=' .github/board-config.env >> $GITHUB_ENV -# -# After that step, every value below is available as both `$NAME` in -# shell scripts and `${{ env.NAME }}` in YAML expressions in subsequent -# steps of the same job. Use `grep` (not `cat`) — $GITHUB_ENV rejects -# lines that don't match `KEY=VALUE`, so comments and blank lines have -# to be filtered out at load time. Regex allows digits in the key to -# match keys like `PRIORITY_P0`. -# -# Security: every ID below is a public identifier (visible to anyone -# with project view access). Nothing in this file is a secret. The -# actual secret — PROJECT_BOARD_TOKEN — stays in repo Secrets (and -# its Dependabot-secrets twin). - -# --- Project --- -PROJECT_OWNER=Wave-RF -PROJECT_NUMBER=7 -PROJECT_ID=PVT_kwDOCdKSOc4BUEKD - -# --- Priority field + options (used by triage.yml) --- -PRIORITY_FIELD_ID=PVTSSF_lADOCdKSOc4BUEKDzhBO0vI -PRIORITY_P0=79628723 -PRIORITY_P1=0a877460 -PRIORITY_P2=da944a9c -PRIORITY_P3=e141a9e0 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c0934c72..0f0ad6a9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -13,6 +13,16 @@ updates: prefix: "deps" # GitHub Actions + # + # TWO entries, because `directory: /` does NOT mean "the whole repo" for + # this ecosystem: it means `.github/workflows/` plus an `action.yml` in the + # repo ROOT. Composite actions in subdirectories are invisible to it. Ours + # lives at `.github/actions/setup-env/action.yml`, so its pins silently + # rotted while the workflow pins advanced around them — by the time this was + # noticed it was two versions behind on `pnpm/action-setup` and a major + # behind on `actions/setup-node` versus the identical actions in + # `publish-npm.yml`. Adding a composite action under `.github/actions/` + # means adding an entry here; nothing else will catch the drift. - package-ecosystem: github-actions directory: / schedule: @@ -27,6 +37,17 @@ updates: commit-message: prefix: "ci" + - package-ecosystem: github-actions + directory: /.github/actions/setup-env + schedule: + interval: weekly + day: monday + groups: + actions-deps: + patterns: ["*"] + commit-message: + prefix: "ci" + # TypeScript packages (npm via pnpm workspace). # # ONE entry at the workspace ROOT — deliberately NOT one per member diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..a1ee16a1 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,46 @@ +# GitHub's auto-generated release notes — the body of every GitHub Release. +# +# GoReleaser hands the body to GitHub (`changelog.use: github-native` in +# .goreleaser.yaml), and GitHub builds it from the PRs merged since the previous +# tag. main is squash-merged, so one PR is one line, titled with its +# Conventional Commit subject. This file is the only knob on that output — the +# `changelog.filters` in .goreleaser.yaml do not apply. +# +# Categories are matched by PR LABEL, top to bottom, first match wins; a PR with +# no matching label falls into the `*` catch-all. So a category is only as good +# as the labels: `dependencies` / `github_actions` / `go` / `documentation` are +# applied automatically by actions/labeler (path-based, see labeler.yml), while +# `breaking-change`, `security`, `bug`, and `enhancement` are applied by hand +# during triage — label the PR, or it lands in "Other changes". +changelog: + exclude: + labels: + - duplicate + - invalid + - wontfix + categories: + # Ordered by what a reader upgrading needs to see first. `!` in the PR title + # marks the breaking change per Conventional Commits; the label is what puts + # it at the top of the notes. + - title: ⚠️ Breaking changes + labels: [breaking-change] + - title: 🔒 Security + labels: [security] + - title: ✨ Features + labels: [enhancement] + - title: 🐛 Bug fixes + labels: [bug] + - title: 📚 Documentation + labels: [documentation] + # The catch-all has to explicitly opt OUT of the dependency labels. Matching + # is first-category-wins, and `*` matches everything — so without this + # `exclude`, every Dependabot PR would land in "Other changes" and the + # Dependencies category below would always render empty. The per-category + # `exclude` is what lets Dependencies both match and still sort last, so a + # long Dependabot run never buries the changes people actually care about. + - title: 🧹 Other changes + labels: ["*"] + exclude: + labels: [dependencies, github_actions] + - title: 📦 Dependencies + labels: [dependencies, github_actions] diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index f7db04df..781f0b9f 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -4,30 +4,66 @@ name: Publish SDK (npm) # ONE trusted publisher (workflow file) per package, so the dev channel and # tagged releases can't be split across two files. Two jobs: # -# dev push to main → 0.0.0-dev. under the `dev` dist-tag, -# published only when the built dist actually changed. The version -# IS a hash of the build output, so an unchanged build maps to an -# already-published version and is skipped. No clients/ts path guard -# (a shared-dep or build-config change is caught too) and no manual -# trigger. The npm analog of the GHCR :dev image (publish-dev.yml). +# dev push to main → 0.0.1-dev..h under the `dev` +# dist-tag, published only when the built dist actually changed. No +# clients/ts path guard (a shared-dep or build-config change is +# caught too) and no manual trigger. The npm analog of the GHCR :dev +# image (publish-dev.yml). # -# release sdk-v* tag → version under `latest` (stable) or +# release clients/ts/v* tag → version under `latest` (stable) or # `alpha`/`beta`/`rc`/`next` (prerelease) + a GitHub Release. The -# SDK analog of release.yml. `v*` (server) and `sdk-v*` (SDK) tag -# globs are disjoint, so this and release.yml never both fire. +# SDK analog of release.yml. Tag globs are anchored at the start of +# the ref name, so `v*` (server) can never match `clients/ts/v*` and +# this and release.yml never both fire. +# +# Tag naming: `clients//vX.Y.Z`, one family per releasable client. Go +# requires exactly this shape for a module in a subdirectory — `go get +# .../clients/go@v1.2.3` resolves only against a `clients/go/v1.2.3` tag +# (https://go.dev/ref/mod) — so every client follows it rather than leaving Go +# as the exception. +# +# The TAG is the version. This job writes the tag's version into package.json +# before publishing rather than checking that someone remembered to bump it: +# the branch ruleset forbids pushing to `main` directly, so a bump commit would +# need its own PR merged before every release. Go and the server already work +# this way (module tags and ldflags), and now npm does too — releasing anything +# in this repo is `git tag && git push`, nothing else. +# +# Dev version scheme (#475) — two properties, in this order: +# +# ORDERED BY RECENCY. Dev versions used to be `0.0.0-dev.h`. semver +# compares alphanumeric prerelease identifiers LEXICALLY, so a content hash +# makes the order arbitrary with respect to time: of the ten publishes on +# that scheme, the newest sorted sixth and a June build was the highest. +# That matters because `npm i @wavehouse/sdk@dev` records a RANGE, not the +# tag — `^0.0.0-dev.h04a…` is satisfied by `0.0.0-dev.hff4…`, so the next +# resolution walked backwards two months and the lockfile pinned it there. +# The `` identifier is numeric, and semver compares numeric +# identifiers numerically, so the channel now orders by publish time. +# +# ISOLATED FROM RELEASES. The base is `0.0.1`, not `0.0.0` and not the +# upcoming release version. `0.0.1-*` outranks every legacy `0.0.0-dev.*` +# (which are permanent — npm's unpublish window is 72 hours), so the poisoned +# history can never win a range again. And because prereleases only satisfy +# ranges anchored to their own MAJOR.MINOR.PATCH, a dev build can never leak +# into `^0.1.0` — nor can a release ever shadow the dev channel. 0.0.1 is +# permanently free: this package's first release was 0.1.0. +# +# The trailing `.h` keeps the build content-addressed: it is what the dev +# job compares against to decide whether there is anything new to publish. # # Auth: OIDC trusted publishing — no NPM_TOKEN. Configure ONE trusted # publisher on npmjs.com → repo Wave-RF/WaveHouse, workflow publish-npm.yml. # Both publish steps pass --provenance for a signed build-provenance attestation # (the OIDC id-token above + public repo + npm >= 11.5.1 from Node 24). # -# To cut a release: bump clients/ts/package.json, commit, then -# `git tag sdk-vX.Y.Z[-pre] && git push origin sdk-vX.Y.Z[-pre]`. +# To cut a release: `make release-sdk-ts VERSION=X.Y.Z` (scripts/release.sh), +# or by hand `git tag clients/ts/vX.Y.Z && git push origin clients/ts/vX.Y.Z`. on: push: branches: [main] - tags: ["sdk-v*"] + tags: ["clients/ts/v*"] permissions: contents: read @@ -72,19 +108,42 @@ jobs: - name: Publish @wavehouse/sdk@dev when the build changes working-directory: clients/ts shell: bash + env: + # The dev channel's own version line, deliberately BELOW any version + # this package will ever release (it shipped 0.1.0 first), and ABOVE + # every legacy `0.0.0-dev.*` publish. See the version-scheme note in + # the header comment. + DEV_BASE: "0.0.1" run: | - # Content-addressed: the version is a hash of the built dist (file - # names + contents), so an unchanged build resolves to a version that - # already exists and we skip. Runs on every push to main; npm stamps - # the commit into the published manifest's gitHead for traceability. + set -euo pipefail + + # Content hash of the built dist (file names + contents) — the build's + # identity, and what decides whether there is anything to publish. hash=$(find dist -type f -print0 | LC_ALL=C sort -z | xargs -0 sha256sum | sha256sum | cut -c1-12) - # h-prefix keeps it a valid semver pre-release identifier even on the - # rare all-digit hash (a numeric identifier can't have a leading zero). - version="0.0.0-dev.h${hash}" - if npm view "@wavehouse/sdk@${version}" version >/dev/null 2>&1; then - echo "Build unchanged — @wavehouse/sdk@${version} already published; nothing to do." - exit 0 - fi + + # Skip only when `dev` ALREADY points at a same-scheme publish of this + # exact dist. Matching on the hash alone would let a legacy + # `0.0.0-dev.h` publish satisfy the check and strand the tag on + # the old, unordered version line forever (a docs-only push to main + # rebuilds a byte-identical dist). + current=$(npm view "@wavehouse/sdk@dev" version 2>/dev/null || true) + case "${current}" in + "${DEV_BASE}-dev."*".h${hash}") + echo "Build unchanged — @wavehouse/sdk@dev is already ${current}; nothing to do." + exit 0 + ;; + esac + + # Publish-time UTC stamp as the FIRST prerelease identifier after + # `dev`. semver compares prerelease identifiers left to right and + # compares numeric ones numerically, so this — not the hash — is what + # orders the channel by recency (#475). The hash stays on the end for + # identity; `h`-prefixed so an all-digit hash can't become a numeric + # identifier with a leading zero. + stamp=$(date -u +%Y%m%d%H%M%S) + version="${DEV_BASE}-dev.${stamp}.h${hash}" + + echo "Publishing ${version} (previous dev: ${current:-})" npm version "${version}" --no-git-tag-version --allow-same-version # --ignore-scripts publishes exactly the dist we just hashed (the # typecheck + build already ran above; this skips a redundant rebuild). @@ -92,8 +151,8 @@ jobs: npm publish --access public --tag dev --ignore-scripts --provenance release: - name: Release (sdk-v* tag) - if: startsWith(github.ref, 'refs/tags/sdk-v') + name: Release (clients/ts/v* tag) + if: startsWith(github.ref, 'refs/tags/clients/ts/v') runs-on: ubuntu-latest timeout-minutes: 15 permissions: @@ -101,6 +160,12 @@ jobs: id-token: write # OIDC for npm trusted publishing steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The GitHub Release step needs to see every `clients/ts/v*` tag to work out + # which one the generated notes should start from. A default checkout + # fetches only the triggering ref, so the tag list would be empty and + # every release would look like the first one. + fetch-tags: true - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: @@ -120,37 +185,91 @@ jobs: id: meta shell: bash run: | - version=$(node -p "require('./clients/ts/package.json').version") - expected="sdk-v${version}" - actual="${GITHUB_REF#refs/tags/}" - if [ "${expected}" != "${actual}" ]; then - echo "::error::Tag '${actual}' != clients/ts/package.json version ('${expected}'). Bump the version in package.json and retag." - exit 1 + set -euo pipefail + ref="${GITHUB_REF#refs/tags/}" + + # The tag is the version. `${ref##*/}` takes the segment after the + # last slash (`clients/ts/v1.2.3` → `v1.2.3`); release-channel.sh has + # already rejected anything that isn't semver-shaped by the time the + # dist-tag is resolved below. + version="${ref##*/}" + version="${version#v}" + + # package.json's committed value is documentation, not the source of + # truth — a bump commit would need its own PR, since the ruleset + # forbids pushing to `main`. Surface the drift, don't fail on it. + declared=$(node -p "require('./clients/ts/package.json').version") + if [ "${declared}" != "${version}" ]; then + echo "::notice::clients/ts/package.json says ${declared}; publishing ${version} from the tag." fi - case "${version}" in - *-alpha*) tag=alpha ;; - *-beta*) tag=beta ;; - *-rc*) tag=rc ;; - *-*) tag=next ;; # any other prerelease form - *) tag=latest ;; - esac + + # Shared with release.yml's GHCR moving tag, so `@wavehouse/sdk@rc` + # and `ghcr.io/wave-rf/wavehouse:rc` can't come to mean different + # things. Stable → latest; prerelease → alpha/beta/rc/next. + tag="$(scripts/ci/release-channel.sh "${ref}")" echo "version=${version}" >> "$GITHUB_OUTPUT" echo "dist_tag=${tag}" >> "$GITHUB_OUTPUT" echo "Publishing ${version} under dist-tag '${tag}'." - name: Publish to npm working-directory: clients/ts - # `npm publish` typechecks + builds via the package's prepublishOnly. - # --provenance attaches a signed build-provenance attestation. - run: npm publish --access public --tag "${{ steps.meta.outputs.dist_tag }}" --provenance + env: + VERSION: ${{ steps.meta.outputs.version }} + DIST_TAG: ${{ steps.meta.outputs.dist_tag }} + shell: bash + run: | + set -euo pipefail + # Stamp the tag's version in. package.json is not the source of truth + # (see "Resolve version" above) so this is what actually decides the + # published version. --allow-same-version keeps a re-run idempotent + # when the committed value already matches. + npm version "${VERSION}" --no-git-tag-version --allow-same-version + # `npm publish` typechecks + builds via the package's prepublishOnly. + # --provenance attaches a signed build-provenance attestation. + npm publish --access public --tag "${DIST_TAG}" --provenance - name: Create GitHub Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VERSION: ${{ steps.meta.outputs.version }} + DIST_TAG: ${{ steps.meta.outputs.dist_tag }} shell: bash run: | - flags=(--title "@wavehouse/sdk ${{ steps.meta.outputs.version }}" --generate-notes) - if [ "${{ steps.meta.outputs.dist_tag }}" != "latest" ]; then + set -euo pipefail + tag="${GITHUB_REF#refs/tags/}" + + # Publishing to npm already happened. Never fail this job over the + # GitHub Release: if one already exists for this tag — because it was + # drafted in the Releases UI, or because the job is being re-run after + # a transient failure — `gh release create` would exit non-zero and + # red an otherwise successful publish. Leave the existing one alone, + # matching GoReleaser's `mode: keep-existing` on the server side, so + # hand-written notes are never clobbered. + if gh release view "$tag" >/dev/null 2>&1; then + echo "::notice::Release $tag already exists — leaving its notes untouched." + exit 0 + fi + + flags=(--title "@wavehouse/sdk ${VERSION}") + if [ "${DIST_TAG}" != "latest" ]; then flags+=(--prerelease) fi - gh release create "${GITHUB_REF#refs/tags/}" "${flags[@]}" + + # Anchor the generated notes to the previous SDK tag. Without this, + # GitHub diffs from the most recent *release* — which in this repo is + # usually a `v*` server release, quite possibly on the very same + # commit, yielding an empty changelog. The glob is anchored at the + # start of the ref name, so it cannot match a server or Go SDK tag. + prev="$(git tag -l 'clients/ts/v*' --sort=-v:refname | grep -vFx "$tag" | head -n1 || true)" + if [ -n "$prev" ]; then + flags+=(--generate-notes --notes-start-tag "$prev") + echo "Generating notes covering ${prev}..${tag}" + else + # First SDK release: there is no earlier `clients/ts/v*` to diff from, and + # letting GitHub pick would hand us the server release on the same + # commit — i.e. nothing. An explicit body beats an empty one. + flags+=(--notes "First release of \`@wavehouse/sdk\`. See the [\`v${VERSION}\` server release](https://github.com/${GITHUB_REPOSITORY}/releases/tag/v${VERSION}) for the full change list.") + echo "No earlier clients/ts/v* tag — using the first-release note." + fi + + gh release create "$tag" "${flags[@]}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 74b1ebb2..67b1b7c2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,6 +11,13 @@ permissions: id-token: write # OIDC for build-provenance attestations (Sigstore) attestations: write # write the build-provenance attestations +concurrency: + # Per-tag, never cancelling: two runs of the SAME tag (a re-run after a + # transient GHCR failure) must not overlap and race each other's pushes, + # but two different tags are independent releases and both must complete. + group: release-${{ github.ref }} + cancel-in-progress: false + jobs: release: name: Release @@ -63,6 +70,44 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + # Resolve the moving GHCR tag BEFORE the build, so a malformed tag + # fails here rather than after an 8-target cross-compile and a + # multi-arch image push. The script rejects anything that isn't + # semver-shaped, so a typo can't fall through to `latest`. + - name: Resolve release channel + shell: bash + run: | + set -euo pipefail + channel="$(scripts/ci/release-channel.sh "$GITHUB_REF_NAME")" + echo "WAVEHOUSE_CHANNEL=${channel}" >> "$GITHUB_ENV" + echo "::notice::${GITHUB_REF_NAME} publishes ghcr.io/wave-rf/wavehouse:${GITHUB_REF_NAME} + :${channel}" + + # Pin which tag the generated release notes are diffed from. Several tag + # families share this repo — `v*` for the server, `clients//v*` for + # each client SDK — and GoReleaser's own detection walks back from the + # parent commit without caring which family it lands on, so a server + # release cut after an SDK release would otherwise describe itself as + # "everything since clients/ts/v0.4.1". Tag globs are anchored at the + # start of the ref name, so `v*` cannot match a `clients/...` tag — which + # is also why this workflow's own `on.push.tags` filter doesn't fire on + # SDK releases. + # + # Left unset when there is no earlier `v*` tag (the first release): with + # no previous tag, GitHub generates notes from the start of history, + # which is exactly right for a first release. Setting it to an empty + # string would not be — GoReleaser reads that as "no changelog". + - name: Resolve previous release tag + shell: bash + run: | + set -euo pipefail + prev="$(git tag -l 'v*' --sort=-v:refname | grep -vFx "$GITHUB_REF_NAME" | head -n1 || true)" + if [ -n "$prev" ]; then + echo "GORELEASER_PREVIOUS_TAG=${prev}" >> "$GITHUB_ENV" + echo "::notice::Release notes will cover ${prev}..${GITHUB_REF_NAME}" + else + echo "::notice::No earlier v* tag — notes will cover the full history." + fi + - name: Run GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: @@ -72,6 +117,10 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Second dockers_v2 tag in .goreleaser.yaml. Stable → latest; + # prerelease → alpha/beta/rc/next, so an rc can't displace the + # :latest a shipped stable release owns. + WAVEHOUSE_CHANNEL: ${{ env.WAVEHOUSE_CHANNEL }} # Build-provenance attestations — free for public repos via Sigstore's # public-good infra. Binaries: one attestation over every artifact listed @@ -99,3 +148,40 @@ jobs: subject-name: ghcr.io/wave-rf/wavehouse subject-digest: ${{ steps.image.outputs.digest }} push-to-registry: true + + # Prove the attestations we just wrote actually verify against the + # artifacts people will download — the same `gh attestation verify` + # command the install docs tell users to run. Attesting and verifying + # are different code paths (subject digests, the checksums-file + # expansion, the registry round-trip), so a release that publishes + # unverifiable provenance should go red rather than look green. + # Runs last: the release is already public by this point, so this is a + # loud alarm, not a gate. + - name: Verify published provenance + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # Digest, not tag: verifying by tag would re-resolve the pointer + # and could check a different image than the one just attested. + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + shell: bash + run: | + set -euo pipefail + + # Every archive listed in checksums.txt is a subject of the binary + # attestation. Verify them all rather than a representative one — + # a per-GOOS/GOARCH gap is exactly what this would miss. + shopt -s nullglob + archives=(dist/*.tar.gz dist/*.zip) + if [ ${#archives[@]} -eq 0 ]; then + echo "::error::no release archives in dist/ to verify" + exit 1 + fi + for archive in "${archives[@]}"; do + echo "--- $archive" + gh attestation verify "$archive" --repo "$GITHUB_REPOSITORY" + done + + echo "--- ghcr.io/wave-rf/wavehouse@${IMAGE_DIGEST}" + gh attestation verify \ + "oci://ghcr.io/wave-rf/wavehouse@${IMAGE_DIGEST}" \ + --repo "$GITHUB_REPOSITORY" diff --git a/.github/workflows/triage.yml b/.github/workflows/triage.yml deleted file mode 100644 index 384195be..00000000 --- a/.github/workflows/triage.yml +++ /dev/null @@ -1,191 +0,0 @@ -name: Triage issues - -# Auto-classifies new and edited issues using GitHub Models and -# applies area/* labels + security / breaking-change flags. -# Optionally writes the Priority field on the project board when -# PROJECT_BOARD_TOKEN (with `project` scope) is configured; -# otherwise the board-write step soft-fails. -# -# Security: issue bodies are untrusted user input. The classifier's -# output only affects labels and a board field, never code -# execution — a prompt-injection attempt can at worst produce a -# wrong label. - -on: - issues: - types: [opened, edited, reopened] - -permissions: - issues: write - contents: read - models: read - -concurrency: - group: triage-${{ github.event.issue.number }} - cancel-in-progress: true - -jobs: - classify: - name: Classify and label - runs-on: ubuntu-latest - timeout-minutes: 10 - # Skip re-triggering on our own label edits. - if: github.actor != 'github-actions[bot]' && github.event.issue.pull_request == null - steps: - - name: Checkout main (trusted board-config source) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.repository.default_branch }} - fetch-depth: 1 - persist-credentials: false - - - name: Load board config - run: grep -E '^[A-Z_][A-Z0-9_]*=' .github/board-config.env >> "$GITHUB_ENV" - - - name: Fetch current area labels - id: areas - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO: ${{ github.repository }} - # Discovers `area/*` labels at runtime so adding a new label - # doesn't require a workflow edit. Label descriptions feed - # the classifier as per-area hints — keep them meaningful. - run: | - gh label list -R "$REPO" --json name,description --jq ' - [.[] - | select(.name | startswith("area/")) - | " \(.name[5:])\(" " * (14 - (.name[5:] | length)))- \(.description // "")" - ] | join("\n") - ' > /tmp/areas.txt - { - echo 'list<> "$GITHUB_OUTPUT" - - - name: Classify with GitHub Models - id: classify - uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1 - with: - model: openai/gpt-4o-mini - # Leaves headroom for multi-area classifications + flags - # without truncating mid-JSON. - max-tokens: 400 - system-prompt: | - You are an issue-triage assistant for the WaveHouse project — a - schema-aware real-time API gateway for ClickHouse written in Go. - Classify the issue. Return only a single JSON object with no - prose and no code fences. - - Areas (pick zero or more from this exact list): - ${{ steps.areas.outputs.list }} - - Priority (pick one, or null if unclear): - P0 - Broken production / data loss / security-critical - P1 - Blocks a major workflow, no workaround - P2 - Important but has a workaround - P3 - Nice-to-have, low urgency - - Flags: - breaking_change: true if the issue proposes a change that - breaks the public API, CLI, or config format. - security: true if the issue is a security vulnerability, - hardening ask, or auth/authz concern. - - Schema (all fields required): - { - "area": ["ingest", "observability"], - "priority": "P2", - "breaking_change": false, - "security": false - } - prompt: | - Title: ${{ github.event.issue.title }} - - Body: - ${{ github.event.issue.body }} - - - name: Apply labels - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RESPONSE: ${{ steps.classify.outputs.response }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - REPO: ${{ github.repository }} - run: | - # shellcheck disable=SC2016 # the sed program legitimately contains backticks in single quotes - # Strip any code fences the model may have added despite instructions. - json=$(printf '%s' "$RESPONSE" | sed -E 's/^\s*```(json)?\s*$//g; s/^\s*```\s*$//g') - echo "Parsed model output:" - echo "$json" - - labels=$(echo "$json" | jq -r ' - ([ (.area // [])[] | "area/" + . ] - + (if .breaking_change == true then ["breaking-change"] else [] end) - + (if .security == true then ["security"] else [] end) - ) | join(",") - ' 2>/dev/null || echo "") - - if [[ -z "$labels" ]]; then - # Distinguish "no labels suggested" (valid empty result) - # from "JSON parse failure" (max-tokens truncation or - # off-script output — surface the raw response). - if ! echo "$json" | jq -e . >/dev/null 2>&1; then - echo "::warning::Classifier output is not valid JSON. Raw response below:" - printf '%s\n' "$RESPONSE" | sed 's/^/ /' - else - echo "::notice::Classifier returned valid JSON but no labels (empty .area, no flags)." - fi - exit 0 - fi - - echo "Applying labels: $labels" - gh issue edit "$ISSUE_NUMBER" --add-label "$labels" -R "$REPO" - - - name: Set Priority on Task Board - # Soft-fail: GITHUB_TOKEN lacks `project` scope, so the - # board write needs PROJECT_BOARD_TOKEN (a PAT with project - # write access). Label triage still works without it. - continue-on-error: true - env: - GH_TOKEN: ${{ secrets.PROJECT_BOARD_TOKEN || secrets.GITHUB_TOKEN }} - RESPONSE: ${{ steps.classify.outputs.response }} - ISSUE_NUMBER: ${{ github.event.issue.number }} - REPO: ${{ github.repository }} - run: | - # shellcheck disable=SC2016 # the sed program legitimately contains backticks in single quotes - json=$(printf '%s' "$RESPONSE" | sed -E 's/^\s*```(json)?\s*$//g; s/^\s*```\s*$//g') - priority=$(echo "$json" | jq -r '.priority // empty' 2>/dev/null || echo "") - - if [[ -z "$priority" || "$priority" == "null" ]]; then - echo "No priority returned; skipping board write." - exit 0 - fi - - # Project IDs come from .github/board-config.env. - case "$priority" in - P0) OPTION_ID="$PRIORITY_P0" ;; - P1) OPTION_ID="$PRIORITY_P1" ;; - P2) OPTION_ID="$PRIORITY_P2" ;; - P3) OPTION_ID="$PRIORITY_P3" ;; - *) echo "Unknown priority '$priority'; skipping."; exit 0 ;; - esac - - item_id=$(gh project item-list "$PROJECT_NUMBER" --owner "$PROJECT_OWNER" --format json --limit 200 \ - | jq -r --argjson n "$ISSUE_NUMBER" --arg repo "$REPO" ' - .items[] - | select(.content.number == $n) - | select(.content.repository == ("https://github.com/" + $repo)) - | .id - ' | head -n1) - - if [[ -z "$item_id" ]]; then - echo "Issue #$ISSUE_NUMBER isn't on the board yet; skipping priority write." - exit 0 - fi - - echo "Setting Priority=$priority on board item $item_id" - gh project item-edit \ - --id "$item_id" \ - --field-id "$PRIORITY_FIELD_ID" \ - --single-select-option-id "$OPTION_ID" \ - --project-id "$PROJECT_ID" diff --git a/.gitignore b/.gitignore index dd45df83..85052bc5 100644 --- a/.gitignore +++ b/.gitignore @@ -67,7 +67,6 @@ Thumbs.db .env .env.local .env.*.local -!.github/board-config.env # Debug __debug_bin* diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 41de3c71..e6ea4d8a 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,13 @@ version: 2 +# GoReleaser defaults ProjectName to the repo directory name — "WaveHouse" — +# which is the only place the product's display casing leaked into an artifact +# identifier: archives built as `WaveHouse_linux_amd64.tar.gz` while the binary +# inside, the GHCR image, and the npm package are all lowercase `wavehouse`. +# Asset URLs are permanent once a release is published, so this is pinned +# rather than inherited. +project_name: wavehouse + builds: - id: wavehouse main: ./cmd/wavehouse @@ -19,6 +27,21 @@ checksum: archives: - name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" + # Windows gets .zip; everything else keeps GoReleaser's tar.gz default. + # Windows has shipped bsdtar since 10 1803, so a .tar.gz is *openable* — + # but not from Explorer, which still only double-clicks into .zip. The + # convention costs nothing and this is the first release whose asset + # names people will script against. + format_overrides: + - goos: windows + formats: [zip] + # Narrowed from GoReleaser's default, which also globs CHANGELOG* — 324 KB + # of development history in every one of the eight archives, for a file + # that is a click away on GitHub and whose contents are the release notes + # the download page already shows. + files: + - LICENSE* + - README* # Suppress the GitHub Release on dev pushes. publish-dev.yml sets # WAVEHOUSE_DEV=1 + a synthetic tag so it can reuse this same pipeline; @@ -30,6 +53,12 @@ archives: # release path. release: disable: '{{ eq (envOrDefault "WAVEHOUSE_DEV" "0") "1" }}' + # `auto` marks the GitHub Release as a pre-release whenever the tag + # carries a prerelease identifier (v0.1.0-alpha.1 → yes; v0.1.0 → no). + # GoReleaser's default is a flat `false`, which would have published the + # first alpha as a full stable release — and GitHub's "Latest release" + # badge keys off exactly this field. + prerelease: auto dockers_v2: - dockerfile: deployments/Dockerfile.goreleaser @@ -37,13 +66,24 @@ dockers_v2: - wavehouse images: - "ghcr.io/wave-rf/wavehouse" - # Tag scheme switches on WAVEHOUSE_DEV: - # release: :{{ .Tag }} (e.g. :v1.2.3) + :latest - # dev: :dev- + :dev (rolling) - # The full SHA matches cleanup-ghcr.yml's `^dev-[0-9a-f]+$` regex. + # Every build gets an immutable reference plus one moving pointer. + # + # immutable release: :{{ .Tag }} (e.g. :v1.2.3) + # dev: :dev- (matches cleanup-ghcr.yml's + # `^dev-[0-9a-f]+$` regex) + # moving release: the channel release.yml derived from the tag via + # scripts/ci/release-channel.sh — :latest for a + # stable tag, :alpha/:beta/:rc/:next for a + # prerelease + # dev: :dev (rolling, follows main) + # + # The channel indirection is why `v1.3.0-rc.1` can't take :latest away + # from a shipped `v1.2.0`. The dev branch stays an explicit literal + # rather than leaning on WAVEHOUSE_CHANNEL's default: a push to main that + # forgot to set the env var must never be able to publish :latest. tags: - '{{ if eq (envOrDefault "WAVEHOUSE_DEV" "0") "1" }}dev-{{ .FullCommit }}{{ else }}{{ .Tag }}{{ end }}' - - '{{ if eq (envOrDefault "WAVEHOUSE_DEV" "0") "1" }}dev{{ else }}latest{{ end }}' + - '{{ if eq (envOrDefault "WAVEHOUSE_DEV" "0") "1" }}dev{{ else }}{{ envOrDefault "WAVEHOUSE_CHANNEL" "latest" }}{{ end }}' platforms: - linux/amd64 - linux/arm64 @@ -57,9 +97,14 @@ dockers_v2: "org.opencontainers.image.created": "{{ .Date }}" changelog: - sort: asc - use: git - filters: - exclude: - - "^docs:" - - "^test:" + # Delegate the release body to GitHub's own "generate release notes" — the + # grouped, linked, per-PR list you get from the Releases UI button, with + # authors and a New Contributors section. `use: git` built the body from raw + # commit subjects instead: nearly the same content (main is squash-merged, so + # each subject IS a PR title) but no links, no authors, and no grouping. + # + # Categorization and exclusions move to .github/release.yml with this. The + # `sort` and `filters` keys that used to live here are gone rather than left + # in place: github-native renders the body on GitHub's side, so GoReleaser + # never sees the commits and both would be silently dead config. + use: github-native diff --git a/AGENTS.md b/AGENTS.md index c569499a..670ba9c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -372,7 +372,7 @@ Internal-only backend changes (middleware refactors, observability internals, de 2. Define an interface if there will be multiple implementations. 3. Wire it into `cmd/wavehouse/main.go`. 4. Document in `docs/src/content/docs/architecture.md`. -5. **Add a matching `area/` repo label** (e.g. `area/foo` for `internal/foo/`) so the issue triage workflow can route issues to it. `triage.yml` discovers `area/*` labels at runtime via `gh label list`, so the new label is picked up automatically — no workflow edit needed. +5. **Add a matching `area/` repo label** (e.g. `area/foo` for `internal/foo/`) so issues can be routed to it during triage, and add the path → label mapping to `.github/labeler.yml` so PRs touching the package get auto-labeled. Issue triage is manual (see §Repository Automation); only the PR-side labeling is automated. ### Writing tests @@ -427,7 +427,7 @@ docs/ → Project documentation ## Repository Automation - **CI** (`ci.yml`): a job DAG over the same Makefile targets as local `make ci` (~3m15s push → green). **The architecture doc is [`.github/workflows/README.md`](.github/workflows/README.md)** — DAG diagram, design invariants, cache policy, and the add-a-job recipe; read it before editing `ci.yml`. The load-bearing facts: the `changes` job (`scripts/ci/classify-changes.sh`, fail-closed) gates the test/docs jobs; the long-pole `e2e` job builds its own SDK dist + cover binary and runs the suite exactly like local `make test-e2e`; each suite uploads a `coverage-` fragment and a dedicated `coverage` job merges them + applies every threshold gate via `make cov` (like local `make ci`'s final step — kept separate so the gate is decoupled from the e2e suite; it's `needs: changes` only and *polls* for the fragments via `scripts/ci/wait-artifact.sh` rather than `needs`-ing the suites, so its ~50s setup overlaps them and the merge fires ~10s after the last suite instead of serializing setup onto the critical path); an **aggregator job named `CI`** is the ruleset's sole required status check (fails on failed/cancelled needs, treats skipped as passing) — the Cloudflare `docs-preview` deploy is deliberately NOT a need (non-gating, like `timing`: only `docs-build` gates, so a slow/failed preview never delays or reds the required check); caches are owned end-to-end by `.github/actions/setup-env` (nested `actions/cache`, automatic post-job saves — never add save steps to `ci.yml`). Plain `make build` binaries are not linked in CI — compile breakage is caught by lint/tests/the cover-binary link, release builds by `goreleaser-validate.yml` / `publish-dev.yml`. Fork PRs run the full secretless pipeline; merge-queue `merge_group` runs re-test the full suite against current main (the queue replaces the old require-up-to-date rule — never remove the `merge_group:` trigger, or queued PRs hang). CI logic lives in `scripts/ci/*.sh`, gated by `make lint-sh` (shellcheck) and `make lint-gha` (actionlint) inside `make verify` — not in inline YAML, except in the trusted-main deploy jobs where inline is the trust boundary. -- **Issue triage** (`triage.yml`): GitHub Models classifies new/edited issues and applies `area/*` + `security` + `breaking-change` labels. +- **Issue triage**: manual. `triage.yml` classified new/edited issues with GitHub Models until GitHub retired the service on 2026-07-30 — the endpoint now returns `410` unconditionally, so the workflow failed on every issue event and was removed along with `.github/board-config.env` and the (now unused) `PROJECT_BOARD_TOKEN` secret ([#431](https://github.com/Wave-RF/WaveHouse/issues/431)). `area/*` / `security` / `breaking-change` labels and the board's `Priority` field are set by hand; maintainers on Claude Code can run the `/pm-triage` skill. PR labeling is untouched — `actions/labeler` in `housekeeping.yml` is path-based. - **Code review** (advisory; the `main branch protection` ruleset is the actual merge gate — its `required_reviewers` rule requires an approval from the `@Wave-RF/wavehouse-admins` team, alongside the required `CI` status check): handled by external marketplace apps (CodeRabbit, Copilot) configured at the org/repo level, not by in-repo workflows. Inline findings post as review threads that `required_review_thread_resolution: true` blocks merge on until resolved. - **Dependabot** (`.github/dependabot.yml`): opens weekly grouped PRs for Go modules, GitHub Actions, and the npm workspaces. They go through the same gate as any PR — an `@Wave-RF/wavehouse-admins` approval (`required_reviewers`) + the required checks — with **no auto-merge** (the former `dependabot-automerge.yml` was removed; auto-approve-and-merge is intentionally off, so every bump gets a human admin review). - **Docs site deploy** (`wavehouse.dev`): the `docs-preview` / `docs-deploy` jobs of the CI workflow (`.github/workflows/ci.yml`), **not** Cloudflare's Workers Builds. Workers Builds can't build this site — `rehype-mermaid` renders diagrams to themed SVG at build time via headless Chromium, and the Workers Builds image has no browser (and no root to apt-install one). CI's `docs-build` job builds `docs/dist/` on a runner with a cached Chromium and uploads it as an artifact; the deploy jobs consume that artifact from a checkout of **trusted `main`** — wrangler, the worker source, and `docs/wrangler.jsonc` never resolve from a PR tree, so PR-authored code can't reach the Cloudflare token (#305); a PR's `docs/worker/` or wrangler-config changes take effect on merge, not in its preview. Push to `main` runs `wrangler deploy` once the whole pipeline is green (production → `wavehouse.dev`); same-repo PR branches run `wrangler versions upload` right after the build (an unrelated test flake no longer blocks the preview), publishing a per-version preview at `-wavehouse-docs.wave-rf.workers.dev` posted as a sticky PR comment. Deploys are skipped when no docs-affecting files changed and on fork PRs. **Requires `CLOUDFLARE_API_TOKEN` + `CLOUDFLARE_ACCOUNT_ID` repo secrets** (referenced only in the two deploy jobs), and Cloudflare Workers Builds must stay **disconnected** from the `wavehouse-docs` Worker (else it double-deploys and fails the browser-dependent build on every push). Wrangler config (custom domain, observability, source maps, preview URLs) lives in `docs/wrangler.jsonc`. The worker (`docs/worker/index.ts`, delegating to `cloudflare-md-router`) deploys alongside the static assets so `Accept: text/markdown` content negotiation works in production. diff --git a/CHANGELOG.md b/CHANGELOG.md index f90e72b1..1b0572ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +## [0.1.0] - 2026-08-18 + +The first public release. Everything below shipped in it — the sections are grouped the way Keep a Changelog asks for, but since there is no previous release to compare against, a reader upgrading from nothing can treat the whole file as "Added". + ### Added +- **`make release-server` / `release-sdk-ts` / `release-sdk-go`** (`scripts/release.sh` (new), `Makefile`, `docs/src/content/docs/development.md`): one command per releasable component, wrapping the preflight checks that are expensive to get wrong — on `main`, clean tree, in sync with `origin/main`, the tag free both locally *and* on the remote, and the required `CI` check green on this exact commit. It then prints what will actually be published (tag, channel, registries) and prompts; `DRY_RUN=1` stops after the plan. The checks are the point: a tag is immutable in practice (the ruleset blocks updates and deletes) and npm's unpublish window is 72 hours, so a release cut from the wrong commit or a red build is not something you take back. The CI-status check is advisory — a missing `gh` or an API hiccup warns rather than blocks, so an API outage can't make releasing impossible. `VERSION` is guarded with `$(origin VERSION)` because the Makefile already `?=`-defaults it to a git-describe string for build stamping, so a forgotten `VERSION=` would otherwise reach the script as `1064a4fe-dirty` and fail with a confusing semver error instead of a usage message. + +- **Release notes now come from GitHub's own per-PR generator** (`.goreleaser.yaml`, `.github/release.yml` (new), `docs/src/content/docs/development.md`): GoReleaser was building the release body from raw commit subjects (`changelog.use: git`). Since `main` is squash-merged with Conventional Commit titles that is nearly the right content already, but with no PR links, no authors, no grouping, and no "New Contributors". `changelog.use: github-native` hands the body to the same generator behind the Releases UI's **Generate release notes** button, and a new `.github/release.yml` groups it — breaking changes and security first, dependencies last. The grouping is by **PR label**, so it is only as good as triage: `dependencies` / `github_actions` / `documentation` come from `actions/labeler` automatically, while `breaking-change`, `security`, `bug`, and `enhancement` are applied by hand, and an unlabelled PR falls to "Other changes". The catch-all category carries an explicit `exclude` for the dependency labels — matching is first-category-wins and `*` matches everything, so without it every Dependabot PR would land in "Other changes" and the Dependencies section would always render empty. Note the `changelog.filters` in `.goreleaser.yaml` no longer apply and were removed; exclusions live in `.github/release.yml` now. `CHANGELOG.md` remains the longer-form record of *why*, and is not the release body. +- **The release job verifies its own provenance** (`.github/workflows/release.yml`, `docs/src/content/docs/development.md`): attesting and verifying are different code paths — subject digests, the `checksums.txt` expansion, the registry round-trip — so publishing an attestation is not evidence that anyone can check it. The job now runs `gh attestation verify` over **every** archive (a per-GOOS/GOARCH gap is exactly what spot-checking one would miss) and over the image by digest rather than by tag, which is the same command the docs tell consumers to run. It runs last, after the release is already public, so it is a loud alarm rather than a gate. +- **Dependabot now watches the `setup-env` composite action** (`.github/dependabot.yml`): `directory: /` does not mean "the whole repo" for the `github-actions` ecosystem — it means `.github/workflows/` plus an `action.yml` in the repo *root*. Composite actions in subdirectories are invisible to it, so `.github/actions/setup-env/action.yml`'s pins quietly rotted while the identical actions in the workflow files were bumped around them: by the time it was noticed it was two patches behind on `pnpm/action-setup` and a major behind on `actions/setup-node`. A second `github-actions` entry pointed at `/.github/actions/setup-env` closes the gap; adding a composite action under `.github/actions/` now means adding an entry here, because nothing else will catch the drift. + - **HTTP customization for the SDK — `options.headers`, `options.fetchOptions`, and `options.fetch`** (`clients/ts/src/types.ts`, `clients/ts/src/client.ts`, `clients/ts/src/http.ts`, `clients/ts/src/index.ts`, `clients/ts/src/client.test.ts`, `docs/src/content/docs/sdk/index.mdx`): closes #269. `ClientOptions` exposed only `maxRetries`, so a WaveHouse behind a header-gated proxy (Cloudflare Access, an mTLS sidecar, an auth gateway) or a cookie-authenticated origin was unreachable from the SDK — a defense-in-depth gate forced consumers off the client entirely. Three knobs land together, shaped after the conventions in Supabase's, OpenAI's, and Anthropic's clients rather than invented here. **`headers`** adds static headers to every REST request; names match case-insensitively as HTTP requires, and they apply *underneath* the SDK's own — `auth` keeps `Authorization`, and a request's `Content-Type`/`Accept` can't be displaced by a global one, since a header joined rather than replaced is how you ship `Content-Type: application/json, image/png`. **`fetchOptions`** merges extra `RequestInit` fields (`credentials: "include"` for the cookie case, `mode`, `cache`, or a runtime extension like Next.js's `next: { tags }`); the fields the SDK controls — `method`, `headers`, `body`, `signal` — always win, so it cannot corrupt the request. **`fetch`** replaces the HTTP implementation outright. All three are REST-only: `.stream()` and `.liveQuery()`'s live connection go through `EventSource`, which accepts neither headers nor a `fetch`, so a header-gated deployment can query but not stream until #203 changes that transport — stated plainly in the docs rather than left implicit, since the equivalent gap in Supabase's realtime client was found the hard way by a user whose RLS policies silently stopped matching. `.liveQuery()`'s initial backfill is an ordinary REST call and *is* covered. Per-call overrides and dynamic header callbacks are deliberately deferred to #459; the per-call slot (`.fetch(opts)`) already exists, so adding them later is additive. The exported `FetchLike` matches the standard `fetch` signature, written out rather than as `typeof fetch` because that resolves differently depending on whether the consumer's TypeScript `lib` includes DOM — the same fragility behind Supabase's long tail of `node-fetch` resolution issues. `options.fetch` accepts any `fetch`-compatible function (exported as the `FetchLike` type) and is used for every request, retries included, so middleware sees each attempt. The motivating case is a runtime bug consumers can't fix themselves: undici 8.8.0–8.9.0 stalls a request before it goes out when a keep-alive socket is reused while the event loop is idle ([nodejs/undici#5600](https://github.com/nodejs/undici/issues/5600), fixed in 8.10.0), and Node 26 bundles 8.9.0 — so calls that should take milliseconds don't, with no recourse inside the SDK. Severity varies with the runtime and the idle gap: the upstream report measured ~450–465 ms, and our own runs against an instant-answering server have ranged from ~100 ms to tens of seconds. Upgrading undici is the real fix, and `options.fetch` is how you get it without waiting for a new runtime: install undici yourself and route through it, passing its dispatcher **explicitly**. That last part is load-bearing and not obvious — undici keeps its connection pool on a shared `globalThis` symbol claimed by whichever copy loads first (Node claims it for the bundled copy on the first built-in `fetch` call, not at startup), so calling an installed 8.10.0's `fetch` without a `dispatcher` resolves whatever is on that symbol and can still stall through the bundled 8.9.0's pool. Measured with both copies loaded, 1.5 s idle gaps against a 10 ms server: `21, 1514, 1495, 583 ms` with an implied dispatcher versus `17, 14, 12, 13 ms` with an explicit `new Agent()`. (Tuning `keepAliveTimeout` is *not* a workaround — measured, it changes nothing, because the retirement timer is starved by the same idle event loop; `new Agent({ pipelining: 0 })` does work for anyone pinned to an affected version, at a connection per request.) The same hook covers the ordinary reasons an SDK grows one: proxies, client certificates, tracing or circuit-breaker middleware, and mocking HTTP in a consumer's own tests without monkey-patching a global. `fetch` stays optional all the way through to the internal `HttpContext` rather than being defaulted at construction, so the default path still calls the global directly — that keeps it late-bound (replacing `globalThis.fetch` after a client exists still works, which is what `vi.stubGlobal` does) and avoids invoking a detached `fetch` reference, which is not universally safe; both properties are pinned by tests. Only the SSE transport is exempt: the live connection behind `.stream()`/`.liveQuery()` goes through `EventSource`, which this does not replace — but `.liveQuery()`'s initial backfill is an ordinary request and does go through the supplied function. Implementations shipping their own request/response declarations (undici, `node-fetch`) need casts on the URL argument, the init, and the return value, since those types are separate from the ones behind the global `fetch`; the narrow documented runtime contract is what makes them safe — a string URL and plain `RequestInit` in, and only `.ok`/`.headers` plus `.text()` on success and `.status`/`.statusText`/`.json()` on a non-`ok` response read back. Abort handling is part of that contract: `ABORTED` requires a `DOMException` named `AbortError` (platform `fetch` and undici), and any other rejection is retried as `NETWORK_ERROR`. - **Non-JWT operator key for full-access admin + break-glass recovery** (`internal/auth/auth.go`, `internal/auth/context.go`, `internal/auth/auth_test.go`, `internal/api/router.go`, `internal/api/router_test.go`, `internal/config/config.go`, `internal/config/config_test.go`, `cmd/wavehouse/main.go`, `config.yaml`, `deployments/compose/standalone.yaml`, `tests/e2e/fixtures/config.yaml`, `docs/src/content/docs/configuration.mdx`, `docs/src/content/docs/access-control.mdx`, `docs/src/content/docs/api.md`, `docs/src/content/docs/deployment.md`, `docs/src/content/docs/development.md`, `docs/src/content/docs/architecture.md`, `docs/src/content/docs/reverse-proxy.mdx`, `AGENTS.md`, `SECURITY.md`): closes #240; partially advances the auth-hardening epic [#228](https://github.com/Wave-RF/WaveHouse/issues/228) and the management/data-plane split [#359](https://github.com/Wave-RF/WaveHouse/issues/359). Adds an optional `auth.operator_key` (`WH_AUTH_OPERATOR_KEY`): a request presenting it — via an `Authorization: Operator ` header (forwarded verbatim by proxies, no collision with Bearer JWTs) or the `X-Operator-Key` alias — is authorized as a full-access platform operator — the whole data plane *and* the `/v1/admin/*` management surface — without minting a JWT and independently of the token verifier, giving the person running the deployment a role-free credential for bootstrap and break-glass. The middleware checks it before the Bearer token with a constant-time comparison (`crypto/subtle`) and stamps two things into the request context: the live `admin_role` (so the policy evaluator's admin bypass grants unrestricted data-plane access while a policy exists) and a platform-operator bit that `RequireAdmin` honors **even when the policy is `nil`/deleted** — the one HTTP path that can restore a wiped policy, which previously required SSH access and a reboot. Empty (the default) disables it, so existing deployments are unchanged; treat it as an admin secret (load from a secret store, serve only over TLS). A successful operator authentication is audit-logged at `INFO`; a request presenting a *non-matching* operator key is logged at `WARN` and counted by a new `wavehouse_auth_operator_key_failures_total` counter (a probing/brute-force signal on the most privileged credential in the system) before falling through to the normal token/default path — the middleware still never rejects. The `Authorization` auth-scheme is matched case-insensitively (RFC 7235) via a shared `authScheme` helper, which also makes the existing `Bearer` JWT scheme case-insensitive (previously it required the canonical `Bearer` casing). Explicitly out of scope, tracked in #359: scoping the operator credential to the management surface only, and capability-scoped admin permissions. @@ -23,74 +33,127 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Site header override — a top-level "Docs" nav link, the search box kept expanded, and a splash-width header** (`docs/src/components/Header.astro` (new), `docs/src/components/Logo.astro` (new), `docs/src/components/WaveMark.astro` (deleted), `docs/src/components/SiteTitle.astro`, `docs/src/components/Footer.astro`, `docs/src/assets/branding/lockup-themed.svg` (new), `docs/src/config/sidebar.ts`, `docs/src/env.d.ts`, `docs/src/styles/global.css`, `docs/scripts/branding/generate.sh`, `docs/astro.config.mjs`): Starlight has no config hook for header links, so we override its `Header` component through the sanctioned `components` map. The custom header re-renders every built-in control (SiteTitle, Search, ThemeSelect, SocialIcons, LanguageSelect) through its `virtual:starlight/components/*` import — so their overrides and Pagefind search keep working untouched — and adds a `