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/release.yml b/.github/release.yml new file mode 100644 index 00000000..de7de312 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,108 @@ +# 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; `*` is a +# catch-all for whatever the categories ABOVE it did not take. So a category is +# only as good as its labels: `github_actions` / `documentation` / `go` / +# `dependencies` 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". +# +# THE INVARIANT: every category keyed on a label that a DEPENDABOT PR CAN CARRY +# must have the Dependabot author exclude. Dependabot is separated by author +# rather than by label, and the final `*` category is what collects it — so any +# earlier category matching one of those labels intercepts bumps before they +# get there. Two sources feed that set, and checking only the first is not +# enough: labeler's path labels (`documentation`, `github_actions`, `go`, +# `dependencies`, `area/*`) AND the ecosystem labels Dependabot applies itself +# (`dependencies`, `javascript`, `go`, `github_actions` — `javascript` is not +# in labeler.yml at all). Two labels are load-bearing today, and both bit: +# +# github_actions labeler maps .github/workflows/** and .github/actions/**, +# so it marks our own CI PRs, not just Dependabot's action +# bumps (#446 is a hand-written CI PR carrying it). Excluding +# the LABEL from the catch-all filed all our CI work under +# Dependencies; dropping it instead empties that category, +# because Dependabot's action bumps carry `github_actions` +# and NOT `dependencies` (#480, #412, #341). +# documentation labeler maps docs/**, and the npm Dependabot config points +# at the workspace root, so every npm bump touches +# docs/package.json and gets labelled. Count only MERGED PRs +# here — an open or superseded bump never reaches a release +# body, and npm group bumps are routinely superseded before +# merge: 3 of the 23 Dependabot PRs merged to date carry it +# (#439, #416, #342). A bump or two per release rather than a +# flood, but the exclude costs nothing and the ratio grows +# with the npm surface. +# +# `security` deliberately has NO author exclude: a Dependabot bump that a +# maintainer hand-labels `security` is a security fix and belongs at the top, +# not buried under Dependencies. +# +# This routing cannot be linted locally, but it CAN be dry-run against real +# history — including from a branch, before this file reaches main, because +# `configuration_file_path` resolves relative to `target_commitish`: +# gh api -X POST repos/Wave-RF/WaveHouse/releases/generate-notes \ +# -f tag_name=vX.Y.Z -f target_commitish= \ +# -f configuration_file_path=.github/release.yml --jq .body +# Do this after editing the categories below. Last run on this config: all 23 +# merged Dependabot PRs landed in Dependencies, none leaked into CI & build +# (41) or Documentation (54). +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] + # ABOVE Documentation deliberately. First match wins, and our CI PRs carry + # BOTH labels — AGENTS.md requires a doc update with every change, so a CI + # PR almost always touches docs/** or README.md too. Of the last 12 human + # PRs carrying `github_actions`, 10 also carry `documentation` (only #446 + # and #283 do not), so with Documentation first this category is dead on + # arrival and Documentation fills with `ci:` PRs. + # + # It is a trade, not a free win, and the cost side is real: a docs PR that + # also edits a workflow now files here. In that same window #290 + # (`feat(docs): live-demo hero panel`) and #277 (`feat(docs): prod-faithful + # dev loop`) both would, as would #187 and #193 further back. Counting only + # PRs that reach these two categories: ~7 genuine CI PRs rescued from + # Documentation against ~2-4 docs PRs misfiled as CI. Worth it because the + # CI population is the one that grows, but re-check the balance before + # copying this decision forward. + - title: 🔧 CI & build + labels: [github_actions] + exclude: + authors: [dependabot, "dependabot[bot]"] + - title: 📚 Documentation + labels: [documentation] + exclude: + authors: [dependabot, "dependabot[bot]"] + - title: 🧹 Other changes + labels: ["*"] + exclude: + authors: [dependabot, "dependabot[bot]"] + # Last, so a long Dependabot run never buries the changes people actually + # care about. `*` rather than a label list: everything reaching this point + # has already been excluded from the categories above by author, so this is + # exactly the Dependabot set — and a bump that somehow carried none of the + # dependency labels still gets listed instead of dropped. + - title: 📦 Dependencies + labels: ["*"] diff --git a/.github/workflows/goreleaser-validate.yml b/.github/workflows/goreleaser-validate.yml index 67f75c94..b09cd84e 100644 --- a/.github/workflows/goreleaser-validate.yml +++ b/.github/workflows/goreleaser-validate.yml @@ -37,6 +37,8 @@ jobs: # goreleaser uses `git describe` to derive the snapshot # version; needs full tag history. fetch-depth: 0 + # Runs on PR-authored code; nothing here needs authenticated git. + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: diff --git a/.github/workflows/publish-dev.yml b/.github/workflows/publish-dev.yml index de51b6ef..7d1793f5 100644 --- a/.github/workflows/publish-dev.yml +++ b/.github/workflows/publish-dev.yml @@ -11,9 +11,11 @@ name: Publish dev image # Release. # # Real tagged releases (`v*`) flow through release.yml against the -# same .goreleaser.yaml without WAVEHOUSE_DEV set, producing -# `:vX.Y.Z` + `:latest`. Cleanup of old dev- tags is handled -# by cleanup-ghcr.yml. +# same .goreleaser.yaml without WAVEHOUSE_DEV set, producing `:vX.Y.Z` +# plus a moving channel pointer — `:latest` for a stable tag, but +# `:alpha`/`:beta`/`:rc`/`:next` for a prerelease, which therefore never +# touches `:latest`. Cleanup of old dev- tags is handled by +# cleanup-ghcr.yml. # # The TypeScript SDK publishes separately — see publish-npm.yml. on: @@ -46,6 +48,9 @@ jobs: # goreleaser reads full git history for changelog + commit # info. Matches release.yml. fetch-depth: 0 + # Same reasoning as release.yml: a third-party action and a + # cross-compile run here, and nothing needs authenticated git. + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml index 31aafb19..17a32dcc 100644 --- a/.github/workflows/publish-npm.yml +++ b/.github/workflows/publish-npm.yml @@ -4,30 +4,67 @@ 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 +# dev push to main → 0.0.1-dev..h under the `dev` +# dist-tag, published only when the PUBLISHED PACKAGE changes — the +# hash covers everything `npm pack` would ship, not just dist, so a +# manifest-only change still republishes. 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 PACKAGE 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 @@ -47,6 +84,11 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Only contents: read here, so this is hygiene rather than exposure — + # but npm lifecycle scripts run in this job too, and nothing needs + # authenticated git. + persist-credentials: false - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: @@ -72,19 +114,72 @@ 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. - 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 + set -euo pipefail + + # Content hash of what npm would actually PUBLISH, not just of dist. + # `dist` alone misses everything else in the tarball: a change to + # `exports`, `files`, `bin`, `engines`, or the bundled README/LICENSE + # leaves dist byte-identical, so the skip check below would suppress a + # publish that genuinely changed the package. `npm pack --dry-run` + # asks npm itself for the file list rather than reimplementing its + # `files`/.npmignore rules. The `version` field is stripped before + # hashing because we are about to compute it from this very hash. + # npm still runs `prepare` here despite --ignore-scripts, and its output + # lands on stdout ahead of the JSON, so slice from the array start. + files=$(npm pack --dry-run --json --ignore-scripts 2>/dev/null \ + | sed -n '/^\[$/,$p' \ + | node -e ' + const p = JSON.parse(require("fs").readFileSync(0, "utf8")); + for (const f of p[0].files) console.log(f.path); + ' | LC_ALL=C sort) + [ -n "$files" ] || { echo "::error::npm pack returned no file list"; exit 1; } + + # Hash the file list plus every file's contents. package.json is + # hashed with `version` removed — we are about to derive the version + # from this hash, so including it would be circular. NOTE: no replacer + # array on JSON.stringify; it filters keys RECURSIVELY, which would + # flatten nested objects (`exports`, `engines`, `publishConfig`) to + # `{}` and silently miss changes to them. + hash=$( { printf '%s\n' "$files" + printf '%s\n' "$files" | while IFS= read -r f; do + if [ "$f" = "package.json" ]; then + node -e 'const p=require("./package.json"); delete p.version; process.stdout.write(JSON.stringify(p))' + else + cat -- "$f" + fi + done + } | sha256sum | cut -c1-12) + + # Skip only when `dev` ALREADY points at a same-scheme publish of this + # exact package. 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 package). + 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 +187,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 +196,18 @@ jobs: id-token: write # OIDC for npm trusted publishing steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # The GitHub Release step resolves which tag the generated notes start + # from with `git describe … "^"`, which needs commit HISTORY, not + # just the tag list — under the default depth-1 checkout `^` does + # not resolve at all. fetch-depth: 0 brings both history and tags. + fetch-depth: 0 + # This job holds `contents: write`, and `pnpm install` plus npm + # lifecycle scripts run before the release step — so a compromised + # dependency would inherit push access from credentials left in + # .git/config. Nothing here needs authenticated git: `git describe` + # is local and `gh release create` uses GH_TOKEN. + persist-credentials: false - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10 with: @@ -120,37 +227,113 @@ 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 + + # --allow-same-version covers `npm version`, NOT `npm publish`: npm + # rejects an already-published version outright. Without this guard a + # re-run after a post-publish failure (the GitHub Release step, a + # network blip) dies here and can never reach the steps that still + # need to run. Idempotent instead. + if npm view "@wavehouse/sdk@${VERSION}" version >/dev/null 2>&1; then + echo "::notice::@wavehouse/sdk@${VERSION} is already published — skipping publish." + exit 0 + fi + # `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. + # + # Selected by walking git HISTORY from this tag's parent, not by + # version-sorting the tag list. Sorting picks "the highest tag that + # isn't this one", which is a different and wrong thing: releasing a + # patch while a newer minor exists selects a previous tag that is + # *ahead* of the current one, and git's versionsort ranks a + # prerelease above its own release, so `v0.2.1` selects + # `v0.2.0-rc.1` over `v0.2.0`. `--match` keeps it inside this + # client's own tag family. + prev="$(git describe --tags --abbrev=0 --match 'clients/ts/v*' "${tag}^" 2>/dev/null || 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. + # Deliberately NOT linking `releases/tag/v${VERSION}`: the server + # and the SDK version independently, so a matching server release + # may not exist — and this path only ever runs once, on the release + # least able to absorb a 404. + flags+=(--notes "First release of \`@wavehouse/sdk\`. See the [repository releases](https://github.com/${GITHUB_REPOSITORY}/releases) 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 7f7b6204..5d0ed079 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 @@ -20,6 +27,13 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 + # The most privileged job here: contents+packages+attestations write, + # running a third-party action, a downloaded goreleaser binary, a full + # `go mod download` + cross-compile, and a Docker build — any one of + # which could read a persisted token out of .git/config and push. No + # authenticated git is needed: GoReleaser talks to the API via + # GITHUB_TOKEN, `git describe` is local, and the fetch is already done. + persist-credentials: false - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: @@ -63,6 +77,18 @@ 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}" + - name: Run GoReleaser uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: @@ -72,6 +98,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 +129,49 @@ 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, flags included, that 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 + # --signer-workflow, not just --repo: `--repo` alone accepts an + # attestation produced by ANY workflow in the repo, so without it this + # step would happily pass on provenance minted somewhere else — which + # is the exact weakness SECURITY.md warns consumers about. Pinning it + # to this workflow is what makes the check mean "release.yml built + # this". + signer="${GITHUB_REPOSITORY}/.github/workflows/release.yml" + + for archive in "${archives[@]}"; do + echo "--- $archive" + gh attestation verify "$archive" \ + --repo "$GITHUB_REPOSITORY" --signer-workflow "$signer" + done + + echo "--- ghcr.io/wave-rf/wavehouse@${IMAGE_DIGEST}" + gh attestation verify \ + "oci://ghcr.io/wave-rf/wavehouse@${IMAGE_DIGEST}" \ + --repo "$GITHUB_REPOSITORY" --signer-workflow "$signer" 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..d809ce7d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,5 +1,25 @@ 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 + +# GoReleaser's tag detection — which tag it is releasing, and which one the +# release notes are diffed from — walks git history without caring which tag +# family it lands on. This repo has several: `v*` for the server and +# `clients//v*` for each client SDK. Without this, cutting `v0.1.0` after +# an SDK release describes the server release as "everything since +# clients/ts/v0.1.0" — verified: `previous=clients/ts/v0.1.0 current=v0.1.0`. +# Ignoring the client families makes the built-in detection correct, which is +# why no workflow needs to pass GORELEASER_PREVIOUS_TAG. +git: + ignore_tags: + - "clients/*" + builds: - id: wavehouse main: ./cmd/wavehouse @@ -19,6 +39,27 @@ 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. + # NOTICE is not optional: the repo is Apache-2.0 and §4(d) makes every + # redistributor of these archives inherit an attribution obligation they + # cannot satisfy from a tarball carrying only LICENSE. GoReleaser's default + # glob never included it either, so this hunk is the moment to fix it — + # archive contents are effectively permanent once published. + files: + - LICENSE* + - NOTICE* + - 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 +71,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 +84,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 +115,23 @@ dockers_v2: "org.opencontainers.image.created": "{{ .Date }}" changelog: - sort: asc - use: git - filters: - exclude: - - "^docs:" - - "^test:" + # The changelog pipe is NOT skipped by `release.disable` — only by + # `--snapshot` or this key (verified: a run with `release.disable: true` + # still logs "generating changelog"). Left on, `github-native` would make + # publish-dev.yml POST /releases/generate-notes on every push to main — an + # endpoint needing `contents: write`, which that workflow deliberately does + # not grant — to build a body that is then discarded, for a synthetic tag + # that exists only on the runner. goreleaser-validate.yml cannot catch it + # either, since `build --snapshot` skips this pipe entirely. + disable: '{{ eq (envOrDefault "WAVEHOUSE_DEV" "0") "1" }}' + # 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 4883e04a..9967534a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -400,7 +400,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 @@ -455,7 +455,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` ([#431](https://github.com/Wave-RF/WaveHouse/issues/431)). The `PROJECT_BOARD_TOKEN` repo secret now has no consumers and **should be deleted, with its PAT revoked** — a branch cannot remove a repo secret, so this is a manual step. `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 2477b7a0..12ed10f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,118 +8,255 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## Unreleased +## [0.1.0] - 2026-08-19 + +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". The date is the intended cut date; correct it if tagging slips, and move anything merged in between up from `## Unreleased`. + ### Added +- **`NOTICE` now ships inside the release archives** (`.goreleaser.yaml`): the repo is Apache-2.0 and carries a root `NOTICE`, and §4(d) makes every redistributor of these archives inherit an attribution obligation they cannot satisfy from a tarball carrying only `LICENSE`. GoReleaser's default file glob never included it either, so this was not a regression — but narrowing the glob was the moment to decide what ships, and archive contents are effectively permanent once published. + +- **The release-notes routing is dry-runnable before merge** (`.github/release.yml`): `POST /releases/generate-notes` accepts `configuration_file_path`, and resolves it relative to `target_commitish` — so the category routing can be exercised against real merged-PR history from a branch, without the config being on the default branch first. The file's comment had said the opposite. Run against this config: all 23 merged Dependabot PRs land in Dependencies, none leak into CI & build (41) or Documentation (54) — the author-exclude invariant confirmed empirically rather than by reasoning. +- **A behavioral test for the release-channel rule** (`scripts/ci/release-channel.test.sh` (new), `Makefile`): `scripts/ci/release-channel.sh` has three consumers and is the single thing keeping `v1.3.0-rc.1` from taking `:latest` and `@latest` away from a shipped stable release, so it should not have been the one piece of release logic without a test. Table-driven over every tag family, the prerelease channels, the build-metadata strip (`v1.0.0+alpha` is *not* a prerelease), and the fail-closed rejections — an unclassifiable tag must never resolve to `latest`. A `verify` leaf alongside `test-classify-paths`, so it gates in CI like a unit test. + +- **`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. A red or still-running `CI` blocks; only a missing `gh` or a commit with no `CI` run at all warns, so a GitHub 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". Dependabot is separated by **author**, not by label, which is load-bearing: `github_actions` is path-based (`actions/labeler` maps `.github/workflows/**` to it), so it marks every hand-written CI PR too — excluding that label from the catch-all filed all of our own CI work under "Dependencies", and simply dropping it instead empties the category, since Dependabot's action bumps carry `github_actions` and *not* `dependencies` (checked against #480, #412, #341). Only the author axis separates the two populations. The same trap applies to `documentation`: the npm Dependabot config points at the workspace root, so every npm bump touches `docs/package.json` and gets labelled. Only *merged* PRs reach a release body, and npm group bumps are usually superseded first, so the live figure is 3 of the 23 Dependabot PRs merged to date — a bump or two per release landing in Documentation rather than a flood, and still worth a zero-cost exclude. The invariant recorded in the file is deliberately wider than the two labels that bit: every category keyed on a label a Dependabot PR *can carry* needs the author exclude — labeler's path labels **and** the ecosystem labels Dependabot applies itself, which are not the same set (`javascript`, on #439/#416/#342, appears nowhere in `labeler.yml`, so a future category keyed on it would satisfy the narrower rule and still intercept every npm bump). `security` deliberately has no exclude, so a hand-labelled Dependabot security fix still sorts to the top. Our own CI work gets its own "CI & build" section as a result — placed *above* Documentation, because AGENTS.md requires a doc update with every change, so a CI PR nearly always carries `documentation` too and first-match-wins would otherwise leave the new section empty. It is a trade rather than a free win — docs PRs that also touch a workflow now file under CI & build — and the counts behind that call live in `.github/release.yml`, which is the one copy kept current. 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, pinning `--signer-workflow` to itself — `--repo` alone accepts an attestation minted by *any* workflow in the repo, the weakness `SECURITY.md` warns consumers about, so without it the check would not establish that this workflow built the artifact. It is the same command, flags included, that the install docs tell consumers to run. It runs last, after the release is already public, so it is a loud alarm rather than a gate. + - **Docs site: WaveHouse Cloud referrals, per-page trademark notices, and attributed outbound links** (`docs/src/components/CloudCta.astro`, `docs/src/components/ExternalIcon.astro`, `docs/src/components/Trademarks.astro`, `docs/src/config/outbound.ts`, `docs/src/config/trademarks.ts`, `docs/src/plugins/rehype-trademarks.ts`, `docs/src/content.config.ts`, `docs/src/components/{Footer,Header,Hero,LiveDemo}.astro`, `docs/src/styles/global.css`, and the pages carrying a CTA): the docs had no path to the managed service, and no consistent way to attribute a visit once someone took one. A new `cloudCta` frontmatter key (`true` for default copy, or `{ title, body }`) opts a page into a CTA panel, deliberately page-specific — the CTA lands hardest when it names the work *that* page just finished describing — the homepage carries the wider `band` variant inline, and its hero's second action now points at Cloud rather than GitHub. Outbound links to Wave RF properties are centralised in `outbound.ts` so every one carries the same UTM params (`utm_source=wavehouse.dev` plus a `utm_content` naming the exact placement, since PostHog reads them off the landing URL). First-party links deliberately get `rel="noopener"` **without** `noreferrer`: `noreferrer` suppresses the `Referer` header that PostHog turns into `$referring_domain`, while `noopener` alone still closes the reverse-tabnabbing hole — third-party links keep both. The referrer survives because Referrer-Policy is never overridden, so the browser default `strict-origin-when-cross-origin` sends the origin only, which is why per-placement detail rides in `utm_content` rather than being inferred from the path. A `rehype-trademarks` plugin appends the ®/™ symbol to each mark's first mention in prose, and `Trademarks.astro` renders the matching per-page attribution notice — both driven off one registry in `trademarks.ts`, replacing a hand-maintained footer blob, and external links get a consistent affordance via `ExternalIcon`. + - **Markdown/MDX lint rules that autofix — WH001 (no hard-wrapped prose) and WH002 (MDX fence beside a JSX tag)** (`scripts/markdownlint-rules/`, `scripts/fix-mdx-fences.mjs`, `.claude/hooks/markdown-on-save.sh`, `.markdownlint.json`, `.markdownlint-cli2.jsonc`, `.vscode/settings.json`, `Makefile`, `package.json`, `AGENTS.md`, `CONTRIBUTING.md`, `docs/src/content/docs/development.md`): two classes of docs defect were being introduced faster than they were caught, both mechanical. **WH001** flags a paragraph broken across lines and joins it — hard-wrapped prose makes a one-word edit land as a five-line diff — skipping tables, code, headings, setext underlines, blockquotes, JSX, and `:::` aside delimiters, and joining a list item as a unit, and turned off for CI docs and agent prompts (`.github/`, `.claude/`) while applying everywhere else. **WH002** flags a code fence sitting directly against a JSX tag. MDX renders that shape correctly (verified by compiling both against the same `@mdx-js/mdx` Astro uses), but markdownlint parses CommonMark, where the tag opens an HTML block running to the next blank line — so the fence is not a code block to any generic rule, and `markdownlint --fix` reformats the code inside it. The blank line keeps the two parsers agreeing. `.mdx` is now linted at all, which it previously wasn't; markdownlint reading MDX as CommonMark turns out to be the feature that exposes WH002's failure mode. The WH002 autofix is a standalone pass (`scripts/fix-mdx-fences.mjs`, sharing its detector with the rule) that must run *before* markdownlint: while the blank line is missing, CommonMark sees no code block, so a YAML block's `#` comments read as ATX headings and MD022/MD023/MD026/MD034 will de-indent them out of the block and rewrite bare URLs inside verbatim code. The generic markdownlint fixers are scoped to `**/*.md` and never run over `.mdx` at all — where markdownlint's CommonMark parse and MDX disagree, an autofix rewrites the inside of a code block, so `.mdx` is checked but structurally fixed only by `fix-mdx-fences.mjs` (misspell still corrects spelling there). The practical cost is that a markdownlint finding in `.mdx` may need fixing by hand ([#499](https://github.com/Wave-RF/WaveHouse/issues/499) tracks doing this properly). The Markdown track of `make fix` is also now serial, since markdownlint and misspell both write the same files. Editors need no setting of their own — the extension reads `customRules` from `.markdownlint-cli2.jsonc` — though it activates on Markdown only, so `.mdx` squiggles nowhere and CI owns it. A `markdown-on-save` PostToolUse hook (sibling of `gofumpt-on-save`) applies the whole chain to agent-written files at write time, so a mechanical defect costs no review round-trip. + - **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 shipped REST-only, because `.stream()` went through `EventSource`, which accepts neither headers nor a `fetch`; **#203 closed that gap within this same unreleased cycle**, so as released they apply to streaming too — see the entry above for the streaming contract they carry. `.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` is written out rather than spelled `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. (Its URL parameter narrows further in the BREAKING entry below, so it is deliberately *not* the standard signature.) `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. Implementations shipping their own request/response declarations (undici, `node-fetch`) need casts on 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: a rejection is reported as `ABORTED` whenever the `AbortSignal` you passed has been aborted, decided from the signal rather than the rejection's type, so `AbortSignal.timeout()` and `node-fetch` behave the same as the platform `fetch`. - **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/ops/*` 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. + - **Missing-dedupe-id observability + optional strict mode** (`internal/api/ingest.go`, `internal/api/ingest_test.go`, `internal/config/config.go`, `cmd/wavehouse/main.go`, `config.yaml`, `deployments/compose/standalone.yaml`, `docs/src/content/docs/configuration.mdx`, `docs/src/content/docs/api.md`, `docs/src/content/docs/architecture.md`): closes #219. With dedupe enabled, a row missing the configured `id_field` can't be deduped — previously it was published with idempotency silently disabled and *no* log or metric, so a producer bug that dropped the id turned off the guarantee for those rows unnoticed. Now every such row is logged at `WARN` and counted by a new `wavehouse_ingest_dedupe_missing_id_total` counter (labeled by `table`), making the loss observable server-side. A new opt-in `dedupe.require_id` (`WH_DEDUPE_REQUIRE_ID`, default `false`) turns that signal into enforcement: a row missing the id is rejected (`400` for a single insert; a per-record failure in a batch) instead of published — a tripwire for producers that must guarantee the id (complements the client-side [#202](https://github.com/Wave-RF/WaveHouse/issues/202)). Default behavior is unchanged. + - **"Durability & Storage" operations guide** (`docs/src/content/docs/durability.md` (new), `docs/src/config/sidebar.ts`, `docs/src/content/docs/reverse-proxy.mdx`, `docs/src/content/docs/configuration.mdx`, `docs/src/content/docs/deployment.md`): documents #84. A new Operations page making the embedded-JetStream durability contract explicit before the docs site publishes: a `200` from `POST /v1/ingest` means the event has been `fsync`'d to disk on the node (the server runs with `SyncAlways: true` in `internal/mq/embedded.go`), which makes the storage substrate's `fsync` tail the ingest latency floor. Covers the contract (and how it differs from JetStream's default page-cache-then-periodic-sync mode), why a slow `fsync` tail manifests as `create stream: ... context deadline exceeded` and `503` backpressure, a where-it's-cheap-vs-expensive substrate table (managed cloud block storage and PLP NVMe vs. ZFS-without-SLOG / qcow2-on-`ext4` / spinning disks), an `fio` recipe + verdict bands to measure your own storage (with the macOS `F_FULLFSYNC` honesty caveat), and the symptom checklist. Forward-references the configurable group-commit interval (`mq.sync_interval`, [#139](https://github.com/Wave-RF/WaveHouse/issues/139)) and the planned `wavehouse storage-check` preflight ([#84](https://github.com/Wave-RF/WaveHouse/issues/84)) without claiming either exists yet. Cross-linked from Configuration (Message Queue), Deployment (Persistent Storage), and the Ingest Pipeline's worker-side ack section; no code changes. + - **"Behind a reverse proxy" deployment guide** (`docs/src/content/docs/reverse-proxy.mdx` (new), `docs/src/config/sidebar.ts`, `docs/src/content/docs/deployment.md`, `docs/src/content/docs/configuration.mdx`, `docs/src/content/docs/api.md`, `internal/api/stream.go`, `internal/api/stream_test.go`): closes #241. A new Operations page for the common "WaveHouse behind nginx / Caddy / Cloudflare Tunnel" setup, since several behaviors only matter behind a proxy and weren't documented together. Covers: TLS termination (WaveHouse serves plain HTTP and manages no certs); the request-body size limits and the division of responsibility (WaveHouse ships fixed in-code memory-safety backstops — 1 MiB control / 16 MiB ingest — while the proxy is the tunable *outer* limit, so a missing/loose proxy limit can't OOM the server); Server-Sent Events buffering + idle-timeout tuning (WaveHouse sends a `: connected` comment on open plus a periodic `:` keepalive comment so quiet streams survive proxy idle timeouts, [#226](https://github.com/Wave-RF/WaveHouse/issues/226)); the `?token=` / `since` / `Last-Event-ID` forwarding streams need; `X-Forwarded-For` trust (don't expose `:8080` directly — it's honored, so a direct client could spoof it); and which health paths to expose (`/livez`/`/readyz` internal-optional, `/v1/health` must stay public). Ships full example nginx, Caddy, and Cloudflare-Tunnel configs, and is cross-linked from Deployment, Configuration, and the API reference. One small code change lands with it: the SSE endpoint (`GET /v1/stream`) now sets `X-Accel-Buffering: no` so nginx-class proxies stream events without buffering out of the box (nginx strips the header before the client sees it; Caddy/Cloudflare ignore it). The health-probe guidance is also upgraded from "optional" to a recommendation — keep the bare `/livez`/`/readyz`/`/healthz` paths internal (a public `/readyz` turns each hit into a ClickHouse `Ping`) and expose only `/v1/health` publicly. + - **Coverage publishing — a self-hosted Go coverage README badge and GitHub Code Quality PR comments** (`.github/workflows/ci.yml`, `.github/actionlint.yaml` (new), `scripts/cov/main.go`, `scripts/ci/publish-badge.sh` (new), `.testcoverage.yml`, `go.mod`/`go.sum`, `README.md`, `AGENTS.md`, `docs/src/content/docs/development.md`): closes #133, now that the repo is public. Two published surfaces, both **non-gating** — `make cov`'s thresholds stay the only merge gate. (1) **README badge**: a new `cov badge` subcommand renders a [shields.io endpoint](https://shields.io/endpoint) JSON for the merged Go total using the *exact* number `threshold.total` gates (same `.testcoverage.yml` excludes), and a new non-gating `badge` job — the sole holder of `contents:write`, running only on trusted main — publishes it to an orphan `badges` branch via `scripts/ci/publish-badge.sh`, which the README reads over `raw.githubusercontent.com` (unrestricted for a public repo). (2) **PR comments**: the `coverage` job converts the merged Go profile to Cobertura (`go tool gocover-cobertura`, a new pinned Go `tool` dependency, with `-ignore-dirs` mirroring the YAML's global excludes) and uploads it to GitHub Code Quality via `actions/upload-code-coverage` (`code-quality: write`); the `github-code-quality[bot]` posts the aggregate + per-file diff-vs-`main` comment. The upload is `continue-on-error` so this public-preview GitHub feature can never red CI, and fork PRs skip it (no `code-quality` token, per GitHub's own guard). `actionlint` doesn't recognize the preview `code-quality` permission scope yet, so a new `.github/actionlint.yaml` suppresses only that one message. Requires the repo's *Settings → Code quality* enablement for the comments to render. Full design in `.github/workflows/README.md` §"Coverage publishing". - **Mermaid diagrams export as PNG, with Copy/Download in the zoom lightbox** (`docs/src/integrations/diagram-png.mjs` (new), `docs/src/components/MermaidZoom.astro`, `docs/astro.config.mjs`, `docs/src/content/docs/development.md`): diagrams render as inline SVG (great for reading — selectable text, screen-reader semantics, theme-reactive colors) but can't be right-click-copied or dropped into a slide deck. A new `astro:build:done` integration now also rasterizes every diagram to `dist/diagrams//-[-transparent].png` — light **and** dark, 2× DPI, in two variants per theme (a WYSIWYG surface card and a transparent-background version for slide decks) — reusing the Playwright Chromium `rehype-mermaid` already needs, with a content-hash cache (`node_modules/.cache/wh-diagram-png`) so unchanged diagrams skip Chromium and the whole pass is wrapped so it can never fail the build. The zoom lightbox (open by clicking any diagram) gains **Copy** (async-clipboard image, falling back to download where unsupported) and **Download** buttons that act on the PNG for the current theme, plus a **background toggle** (checkerboard icon) that flips Copy/Download between the solid and transparent variant and previews transparency as a checkerboard on the zoom stage; inline diagrams stay button-free. The upstream `@wave-rf/astro-themed-mermaid` plugin is intentionally untouched — it's color-agnostic (CSS-var placeholders resolved at runtime), so a WYSIWYG light/dark PNG must be rasterized post-build where `global.css` + a theme apply. PNGs are gitignored build artifacts, regenerated in CI/deploy. - **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 `