Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Fixed

- **`classify-paths.sh` no longer reads a `grep` failure as "no match"** (`scripts/classify-paths.sh`, `scripts/classify-paths.test.sh`): both decisions were `if printf … | grep -qE …; then A; else B; fi`. `grep` exits `0` on match, `1` on no match and **`2` on error** (can't fork/exec, read error, bad pattern), and the `else` branch collapsed `1` and `2` into the same answer — `set -euo pipefail` does not help, since `set -e` is suppressed for a command used as an `if` condition. Observed twice in local `make ci` runs whose static checks run at `-j 14`: a different single case failed each time (`mixed-docs-go` answering `docs=false`, then `dep-bump-go` answering `code=false`) while every other case passed, which is the signature of a transient `grep` failure rather than a pattern bug. The test caught it only because it asserts expected values; **the production path has no such check** — CI's `changes` job gates the docs pipeline on this answer, so a `docs=false` produced by an errored `grep` silently skips the docs build and still reports success. The two greps now go through a `matches` helper that aborts with a diagnostic on any exit above 1, and the test suite stubs `grep` onto `PATH` to prove the abort fires (that case fails against the previous script). A second instance of the same class, found reviewing the first fix: the helper piped its input into `grep -q`, which exits at the first match — so once the file list outgrew the pipe buffer (a few thousand paths) the upstream `printf` died of SIGPIPE, `pipefail` reported 141, and the new error arm aborted on an ordinary large change set. Reproduced at 5,000 paths. It now reads from a here-string instead, and the suite pins that case. `scripts/ci/classify-changes.sh` also stopped reading the classifier through process substitution, which discarded its exit status: a classifier that aborted left `code`/`docs` empty, every `needs.changes.outputs.code == 'true'` job skipped, and the `CI` aggregator reported green having run nothing. It now captures the status, and fails closed — running everything — on a failed *or* partial classification, matching the rule already used for an empty file list. Also here, unrelated and one line: `biome.json` declared `$schema` 2.4.15 while the lockfile pins the 2.5.8 CLI, so `biome check --error-on-warnings` failed on the config itself for any change touching TypeScript. Bumped to match; it changes no lint rule.

- **The Claude Code page pointed at an observability stack that never shipped** (`docs/src/content/docs/claude-code.md`): the MCP-servers section told readers that "when [#121](https://github.com/Wave-RF/WaveHouse/issues/121) lands a SigNoz dev stack with `make dev-obs`", Grafana MCP would become a natural choice for trace / log inspection. Wrong three ways: #121 closed as *completed* on 2026-05-24, `make dev-obs` exists nowhere in the tree (the real targets are `obs-aspire` / `obs-grafana` / `obs-front`), and the project deliberately went the other way — `docs/src/content/docs/deployment.md` states outright that no heavy multi-node cluster like SigNoz is maintained for local development, and `deployments/signoz/` is gone. So the page deferred to future work that had already shipped under a different name, and handed readers a command that errors out. It now points at `make obs-grafana` (a single-container Grafana LGTM stack) and links the deployment section. Caught by the docs review on the WH001 change above rather than by anything in that change — the two share only the observation that prose outliving its subject is invisible until someone reads the whole file.

### Security
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.8/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
27 changes: 26 additions & 1 deletion scripts/ci/classify-changes.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,38 @@ if [ -z "$files" ]; then
fi

# Pure classification of the file list, then the push/merge-group override.
#
# Capture the classifier's output and its EXIT STATUS before parsing. Reading it
# through process substitution (`done < <(…)`) discards the status, so a
# classifier that aborted mid-run left code/docs empty or half-set — every
# `needs.changes.outputs.code == 'true'` job would skip and the `CI` aggregator
# would go green having run nothing. Same fail-closed rule as the empty-list
# case above: if we cannot classify, run everything.
# Capture the status into rc first: `if ! cmd` inverts the test AND resets $?,
# so reading $? inside the branch always yields 0 — the diagnostic would lie
# about the very failure it exists to report.
classified=""; rc=0
classified="$(printf '%s\n' "$files" | "$here/../classify-paths.sh")" || rc=$?
if [ "$rc" -ne 0 ]; then
echo "classify-changes: classifier failed (exit $rc) — failing closed, running everything" >&2
emit true true
exit 0
fi

code=""; docs=""
while IFS='=' read -r key value; do
case "$key" in
code) code="$value" ;;
docs) docs="$value" ;;
esac
done < <(printf '%s\n' "$files" | "$here/../classify-paths.sh")
done <<<"$classified"

# A partial or unparseable answer is as untrustworthy as a failed one.
if [ -z "$code" ] || [ -z "$docs" ]; then
echo "classify-changes: incomplete classification (code='$code' docs='$docs') — failing closed" >&2
emit true true
exit 0
fi

if [ "${GITHUB_EVENT_NAME}" = "push" ] || [ "${GITHUB_EVENT_NAME}" = "merge_group" ]; then
code=true
Expand Down
27 changes: 25 additions & 2 deletions scripts/classify-paths.sh
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,37 @@ fi
# code=true unless EVERY changed path matches the prose/meta allowlist.
# `grep` here is case-sensitive: GitHub meta paths use their real casing
# (`.github/ISSUE_TEMPLATE/`, `.github/PULL_REQUEST_TEMPLATE.md`).
if printf '%s\n' "$files" | grep -qvE '^(docs/|.*\.md$|LICENSE|NOTICE|\.gitignore$|\.gitattributes$|\.github/labeler\.yml$|\.github/ISSUE_TEMPLATE/|\.github/PULL_REQUEST_TEMPLATE|\.claude/|\.vscode/)'; then
# matches reports whether grep found anything, distinguishing "no match" (exit 1)
# from "grep failed" (exit 2+: can't fork/exec, read error, bad pattern). An `if
# grep -q ...` collapses those two into the same branch, so a transient failure
# under load answers the question wrongly and confidently — and `set -e` does not
# help, because it is suppressed for a command used as an `if` condition. CI's
# `changes` job gates the docs pipeline on this answer, where a wrong `docs=false`
# skips the docs build and still reports success. Fail loudly instead.
matches() {
local rc=0
# Here-string, not a pipe: `grep -q` exits at the first match, and once $files
# exceeds the pipe buffer (~64 KB here, a few thousand paths) the upstream
# printf dies of SIGPIPE, which `set -o pipefail` reports as 141 — landing in
# the error arm below and aborting on a perfectly ordinary large change set.
# That is the same failure class this helper exists to close, through a
# different door.
grep -qE "$@" <<<"$files" || rc=$?
case $rc in
0) return 0 ;;
1) return 1 ;;
*) echo "classify-paths: grep failed (exit $rc)" >&2; exit 2 ;;
esac
}

if matches -v '^(docs/|.*\.md$|LICENSE|NOTICE|\.gitignore$|\.gitattributes$|\.github/labeler\.yml$|\.github/ISSUE_TEMPLATE/|\.github/PULL_REQUEST_TEMPLATE|\.claude/|\.vscode/)'; then
echo "code=true"
else
echo "code=false"
fi

# docs=true when ANY changed path is a docs-site build input.
if printf '%s\n' "$files" | grep -qE '^(docs/|clients/ts/|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.github/workflows/ci\.yml|\.github/actions/setup-env/)'; then
if matches '^(docs/|clients/ts/|pnpm-lock\.yaml|pnpm-workspace\.yaml|\.github/workflows/ci\.yml|\.github/actions/setup-env/)'; then
echo "docs=true"
else
echo "docs=false"
Expand Down
36 changes: 36 additions & 0 deletions scripts/classify-paths.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,42 @@ check mixed-docs-go true true docs/x.md internal/a.go
# Empty change set (no paths) — the empty guard, distinct from "a blank line".
check empty false false

# A failing grep must abort, not answer. Without this the script reads exit 2
# ("grep couldn't run") as exit 1 ("no match") and prints a confident wrong
# answer — which is how a transient failure under parallel load silently
# skipped the docs build. Stub grep onto PATH so it always exits 2.
stub="$(mktemp -d)"
printf '#!/bin/sh\nexit 2\n' > "$stub/grep"
chmod +x "$stub/grep"
out="$(printf 'docs/x.md\n' | PATH="$stub:$PATH" "$classify" 2>&1)"
rc=$?
rm -rf "$stub"
if [ "$rc" -eq 2 ] && printf '%s' "$out" | grep -q 'grep failed'; then
printf ' ok %-18s aborts instead of answering\n' "grep-failure"
else
printf ' FAIL %-18s want exit 2 + diagnostic, got exit %s: %s\n' "grep-failure" "$rc" "$out" >&2
fails=$((fails + 1))
fi

# A large change set must classify normally. `grep -q` exits at the first match,
# so feeding it through a pipe made the upstream printf die of SIGPIPE once the
# list outgrew the pipe buffer — which `pipefail` reported as a grep failure and
# the script turned into an abort. In CI that abort is silent: the wrapper reads
# the classifier through process substitution, so every code job would skip and
# the aggregator would report green having run nothing.
big="$(mktemp)"
i=0
while [ "$i" -lt 5000 ]; do printf 'docs/file%05d.md\n' "$i" >> "$big"; i=$((i + 1)); done
big_out="$(env "$classify" < "$big")"
big_rc=$?
rm -f "$big"
if [ "$big_rc" -eq 0 ] && printf '%s' "$big_out" | grep -qx 'docs=true' && printf '%s' "$big_out" | grep -qx 'code=false'; then
printf ' ok %-18s classifies without SIGPIPE\n' "large-input"
else
printf ' FAIL %-18s want exit 0 + code=false/docs=true, got exit %s: %s\n' "large-input" "$big_rc" "$(printf '%s' "$big_out" | tr '\n' ' ')" >&2
fails=$((fails + 1))
fi

if [ "$fails" -gt 0 ]; then
printf '\n%d case(s) failed\n' "$fails" >&2
exit 1
Expand Down