Files
pad/Makefile
T
xarmian a1716d8170 ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881) (#1247)
* ci(web): decide the npm audit gate from the report, not the exit code, and run it last (BUG-2881)

`npm audit` exits non-zero identically for "a HIGH/CRITICAL advisory
exists" and "the advisory service was unreachable". The Web job ran it
before Build / Type check / vitest under `bash -e`, so a registry
timeout (main, 03:50Z) and a 503 (#1246, 04:33Z) on 2026-09-04 each
produced a red row with every frontend verification step SKIPPED — a
lane that read like a failure and had asked nothing.

scripts/ci-audit.mjs runs the audit in --json mode and decides from the
report: metadata.vulnerabilities present → fail iff high+critical > 0,
naming the advisories; an error envelope or unparseable output → a
GitHub warning annotation saying the gate did not run, exit 0. The step
moves to the end of the job so the frontend's own verdict always exists
whatever the audit does.

Verified locally against five report shapes (transport timeout envelope,
E503 envelope, one high advisory, clean, garbage) and two live runs (the
real registry: clean; a dead registry: warning, exit 0). `--input <file>`
is the seam those checks use.

Fixes BUG-2881

* ci(web): the audit gate fails closed — retry an unreachable advisory service, then fail under its own title

Codex round 1 on #1247: the first draft warned and exited 0 when the
advisory service could not be asked, which made the only supply-chain
gate pass exactly when it had not run. A gate that passes when it cannot
run is not a gate.

Now: up to three attempts with backoff (registry blips are usually
seconds long), then `::error title=npm audit did not run` and exit 1.
The title is distinct from `::error title=npm audit` (a real advisory)
so the checks tab tells the two apart without opening the log; re-running
is the remedy for the first and never for the second. Because the step
runs last, Build / Type check / vitest have already produced their result
either way — the original blindness is gone regardless of which way this
step fails.

Verified against the same five saved shapes (transport and E503 envelopes
and garbage now exit 1 under the did-not-run title; a high advisory exits
1 under the advisory title; clean exits 0) and two live runs (real
registry: clean; dead registry: three attempts logged, exit 1).

Refs BUG-2881

* ci(web): the audit gate refuses counts it cannot read, and refuses bad tuning without crashing

Codex round 2 on #1247. (1) metadata.vulnerabilities was checked for
presence, not for shape: Number("x") + Number(null) > 0 is false, so a
malformed count read as a clean audit — a second fail-open, one layer
deeper than round 1's. high/critical must now be non-negative integers
or the report is unreadable, which is the fail-closed path. (2) The two
env knobs are operator-set, but CI_AUDIT_ATTEMPTS=NaN left the retry loop
unexecuted and threw a TypeError, and CI_AUDIT_BACKOFF_MS=Infinity parked
Atomics.wait forever; both now fall back to the default with a line
saying so.

Refs BUG-2881

* build: the local preflight runs the same audit gate CI does, and runs it last

Codex round 3 on #1247 (blast radius): `make web-check` still chained
bare `npm audit && npm run check`, so a registry blip stopped svelte-check
locally exactly as it had in CI, and CONTRIBUTING documented the bare
command as the way to reproduce the gate. New `web-audit` target runs
`npm run audit:ci`; `check` runs it after web-check and web-test, mirroring
the Web job's order. CONTRIBUTING and docs/architecture.md say so.

Refs BUG-2881

* build: web-audit stands alone — no `web` prerequisite, so `check` runs npm ci once and no new target reaches it

Codex round 4 on #1247: `web-audit: web` made `check` run `npm ci` twice
(`web` is .PHONY) and added a target CLAUDE.md's worktree rule did not
list as reaching `npm ci`. `npm audit` reads the lockfile and needs
neither node_modules nor a build — verified by running it with
node_modules removed — so the prerequisite goes; CLAUDE.md's safe list
gains `web-audit`.

Refs BUG-2881
2026-09-04 10:45:21 -04:00

191 lines
8.2 KiB
Makefile

.PHONY: build test test-pg test-pg-down dev clean web dev-web serve restart lint install check vuln web-check web-test web-audit
BINARY=pad
BUILD_DIR=./cmd/pad
HOST?=127.0.0.1
INSTALL_DIR?=$(HOME)/.local/bin
VERSION ?= dev
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME)
# Pin to the same golangci-lint and govulncheck versions CI runs (see
# .github/workflows/ci.yml). Bump these and CI together when upgrading.
GOLANGCI_LINT_VERSION ?= v2.11.4
GOLANGCI_LINT := $(shell go env GOPATH)/bin/golangci-lint
GOVULNCHECK_VERSION ?= v1.2.0
GOVULNCHECK := $(shell go env GOPATH)/bin/govulncheck
build: web
go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(BUILD_DIR)
build-go:
go build -ldflags "$(LDFLAGS)" -o $(BINARY) $(BUILD_DIR)
install: build
@# Stop running server, install binary, clear stale pid.
@# CAUTION: `pkill -x pad` is SYSTEM-WIDE (matches the binary name on the
@# whole host). If another user or project on the same machine is running
@# a `pad` process, it will get signaled too. Designed for single-developer
@# local setups; don't run `make install` on a shared host.
@#
@# SIGTERM (not SIGKILL) so the server's graceful-shutdown path runs:
@# it closes the event bus, which terminates SSE handler goroutines so
@# the http.Server can write the final 0-chunk before closing each
@# stream. SIGKILL drops every open SSE connection mid-write, leaving
@# every browser tab with `ERR_INCOMPLETE_CHUNKED_ENCODING` and a noisy
@# reconnect storm. Falls back to SIGKILL after 5s for stuck processes.
@# BUG-1531 / SSE follow-up.
-pkill -TERM -x $(BINARY) 2>/dev/null; \
for i in 1 2 3 4 5; do \
pgrep -x $(BINARY) >/dev/null 2>&1 || break; \
sleep 1; \
done; \
pkill -KILL -x $(BINARY) 2>/dev/null || true
@mkdir -p $(INSTALL_DIR)
cp -f $(BINARY) $(INSTALL_DIR)/$(BINARY)
rm -f ~/.pad/pad.pid
@echo "Installed $(BINARY) to $(INSTALL_DIR)/$(BINARY)"
@# Trigger server auto-start by running a command
@$(INSTALL_DIR)/$(BINARY) auth whoami 2>/dev/null || true
@echo "Server restarted."
# -timeout matches CI (see .github/workflows/ci.yml). Without it `go test`
# uses a 10m per-test-binary default nobody chose — the shape that killed
# the v0.13.0 release pre-flight (TASK-2545).
test:
go test -timeout=45m ./... -v
# Run tests against PostgreSQL (starts a container automatically).
# Uses port 5445 to avoid conflicts with any local PostgreSQL.
test-pg:
docker compose -f docker-compose.test.yml up -d --wait
PAD_TEST_POSTGRES_URL="postgres://pad:pad@localhost:5445/pad?sslmode=disable" go test -timeout=45m ./... -v -count=1; \
EXIT_CODE=$$?; \
docker compose -f docker-compose.test.yml down -v; \
exit $$EXIT_CODE
test-pg-down:
docker compose -f docker-compose.test.yml down -v
dev: build-go
./$(BINARY) server start --host $(HOST)
serve: build
-./$(BINARY) server stop 2>/dev/null
@sleep 1
./$(BINARY) server start --host $(HOST)
restart: build-go
-./$(BINARY) server stop 2>/dev/null
@sleep 1
./$(BINARY) server start --host $(HOST)
web:
cd web && npm ci && npm run build
dev-web:
cd web && npm run dev
clean:
rm -f $(BINARY)
rm -rf web/build
go clean ./...
# Run the same golangci-lint suite CI runs (.golangci.yml: govet,
# ineffassign, staticcheck SA*, unused, plus the gofmt formatter with
# simplify: true). The lint suite already includes go vet via the govet
# linter, so we don't double-run it here.
#
# Version enforcement: the recipe checks the installed binary against
# GOLANGCI_LINT_VERSION and reinstalls on mismatch. A file-target
# dependency wouldn't enforce the pin — make only runs the install rule
# when the binary is missing, so an outdated local binary would be
# silently reused and disagree with CI (Codex review on PR #322).
lint:
@bin="$(GOLANGCI_LINT)"; pin="$(GOLANGCI_LINT_VERSION)"; want="$${pin#v}"; \
have=$$( $$bin version 2>/dev/null | sed -n 's/.*version \([0-9.]*\) built.*/\1/p' ); \
if [ "$$have" != "$$want" ]; then \
echo "Installing golangci-lint $$pin (had: $${have:-none})..."; \
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$$pin; \
fi
$(GOLANGCI_LINT) run --timeout=5m ./...
# Run govulncheck in BINARY mode against a freshly-built pad binary, NOT
# source mode (`govulncheck ./...`). Source mode builds an SSA call-graph
# over the entire dependency tree (BigQuery / OTel / gRPC / Google Cloud),
# which balloons to multiple GB of RAM and can lock up a memory-constrained
# host (BUG-2084). Binary mode reads the compiled binary's symbol table
# instead: a fraction of the memory, still call-graph-precise (it walks the
# binary's symbol graph), and it detects stdlib vulns from the Go version
# stamped in the binary. Because `pad` is a single binary containing the
# whole codebase (server + CLI), scanning it covers everything; not-called
# module vulns are naturally suppressed since their symbols aren't linked in.
# Mirrors the "Run govulncheck" step in CI's Go job — keep the two in sync.
#
# The build needs web/build to exist for the //go:embed directive. Locally
# `make web` / `make install` provides the real assets; the guard below
# drops a placeholder when it's absent (e.g. a fresh clone) so a standalone
# `make vuln` never fails on the embed. `go install foo@vX.Y.Z` is idempotent
# and rebuilds quickly when the pinned version is already cached.
#
# The scan binary is written to the repo root (real disk), NOT /tmp: some
# hosts mount /tmp as a small tmpfs (RAM-backed), where a large embedded
# binary can hit "no space left" and, worse, consume the very RAM we're
# trying not to exhaust (BUG-2084). It's removed on completion and
# .gitignore'd so an interrupted run can't leave a tracked artifact.
vuln:
go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)
@[ -n "$$(ls -A web/build 2>/dev/null)" ] || { mkdir -p web/build && echo placeholder > web/build/.gitkeep; }
go build -o pad-vulnscan ./cmd/pad
$(GOVULNCHECK) -mode binary pad-vulnscan; status=$$?; rm -f pad-vulnscan; exit $$status
# Web pre-flight that mirrors CI's Web job beyond the build step: svelte-check
# type checking. Depends on `web` so npm ci + build are already done.
# Separate target so a contributor iterating on the UI can run just the
# extra checks via `make web-check`.
web-check: web
cd web && npm run check
# Run the web unit-test suite (vitest, non-watch). Mirrors the "Run web unit
# tests" step in CI's Web job. Kept separate from web-check so a contributor
# can run just the vitest suite via `make web-test`; `check` invokes both.
web-test:
cd web && npm run test
# `npm audit` (production dependencies, high severity+), through the same
# gate CI uses — web/scripts/ci-audit.mjs — and, like CI, LAST (BUG-2881).
# Bare `npm audit` exits non-zero the same way for "an advisory exists" and
# "the advisory service was unreachable", and with `&&` in front of the other
# checks a registry blip used to stop svelte-check and vitest from running at
# all. The script tells the two apart, retries the second, and fails closed
# under its own title; running it after the correctness checks means their
# verdict exists whichever way it goes.
#
# NO `web` prerequisite (codex round 4 on #1247): `npm audit` reads the
# lockfile and needs neither node_modules nor a build, and `web` is .PHONY,
# so depending on it made `check` run `npm ci` twice and made this the one
# new target to reach `npm ci` — the command CLAUDE.md forbids in a worktree
# with a symlinked node_modules. Standalone, it is safe to run anywhere.
web-audit:
cd web && npm run audit:ci
# Pre-flight target that mirrors CI's Go and Web jobs. Run this before
# pushing — if it passes, the corresponding CI checks should pass too.
#
# Covers: golangci-lint suite (lint), Go test suite, govulncheck, npm ci,
# web build, svelte-check, vitest unit tests, npm audit (last, see web-audit). The race-detector +
# Postgres jobs only run on push to main (per .github/workflows/ci.yml) and
# are not included here; run `make test-pg` separately if you want them
# locally.
#
# `make install` stays lightweight (build + restart only) so the inner
# dev loop is fast; opt into `check` when you're ready to push.
check: lint
go test -timeout=45m ./...
$(MAKE) vuln
$(MAKE) web-check
$(MAKE) web-test
$(MAKE) web-audit