Skip to content

ci: test main after merge, and harden how a build is served - #26

Merged
mikim merged 1 commit into
mainfrom
claude/ci-config-hardening
Sep 22, 2026
Merged

mikim merged 1 commit into
mainfrom
claude/ci-config-hardening

Conversation

@mikim

@mikim mikim commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Part of a series from a review of the monitor. This one touches no src/ code: CI, build config, and how a build is served.

CI never tested main

push had branches-ignore: [main], and pull_request tests a PR merged onto main as main stood at the PR's last push. So the tree that actually lands — and gets deployed — was never checked, while every PR push ran twice (once per event, in different concurrency groups).

Now: push on main only, pull_request with no branch filter, workflow_dispatch. Main is tested after every merge, each PR push runs once, and a stacked PR (base = another feature branch) still gets its check. What is deliberately not covered — a branch with no open PR, and a PR whose base was retargeted without a new push — is spelled out in ci.yml.

The build-time code was unchecked

The health.json plugin in vite.config.ts is the only build-time code in the repo, and Vite loads that file through esbuild, which strips types without checking them. A mistyped emitFile option (filename for fileName) builds cleanly and quietly takes /api/health away.

  • tsconfig.json now includes vite.config.ts (confirmed: that typo becomes TS2561).
  • scripts/check-health-json.mjs runs after the build and fails on a missing or malformed document, including the static-artifact contract (timestamp === buildTime, pipeline: "none", no lastProcessedAt, a real commit). tests/check-health-json.test.ts feeds it one wrong document per rule.

Serving

  • Directory listing. serve lists directories by default, as HTML or JSON, and has no CLI flag to turn it off. public/serve.json (directoryListing: false) is copied into dist/ by the build, so it applies however serve is started on dist/. tests/static-serving.test.ts spawns the real serve with ecosystem.config.cjs's own args and pins both this and the existing 404-not-index.html rule. Removing serve.json fails 2 tests; re-adding -s fails 4.
  • Security headers in the reference nginx config never applied. They sat at server level, but nginx only inherits add_header into a location that sets none of its own, and every location sets one. They now live in a snippet each location includes, plus HSTS. The API proxies hide the upstream's copies so no response carries duplicates.
  • CSP. location / gets a Content-Security-Policy. The current bundle runs under it with zero violations (map, all tabs, tooltips, mobile drawer; 1440×900 and 375×812). An injected onerror= probe was blocked. The comment gives Report-Only as the rollout step and explains the connect-src coupling: a statusUrl moved off *.moss.land would read as not health-checked, never as down.
  • Phaser chunk. Phaser is ~95% of the JS and changes only with the lockfile. It is now its own chunk (phaser-*.js, 330 kB gzip; app 18 kB gzip), so an app-only deploy stops invalidating it for returning visitors. Vite's size warning is global, so it is raised just above Phaser, and a small plugin re-applies the default 500 kB to app chunks. That plugin also warns if the split ever stops working.
  • Manifest. Dropped orientation: landscape, which locked installed Android phones into the cramped tablet layout, and updated the description.

What is verified, and what is not

  • ✅ typecheck, 32 tests (was 18), build, and check-health-json on the real output
  • ✅ serve behaviour checked with curl on a real build: / 200, /assets/ and /assets 404 (no HTML or JSON listing), real asset 200, missing asset 404
  • ✅ CSP run against the real bundle in headless Chrome, as above
  • ⚠️ nginx -t was not run (no nginx locally); the structure was checked by hand. The example is reference-only: the live vhost needs the same edit by hand, ideally with the CSP as Report-Only first.
  • ⚠️ serve reads serve.json at startup, so the origin process needs a restart after the build that adds it.
  • Not in this PR: a deploy script, and the README items unrelated to CI/serving (a later docs PR).

🤖 Generated with Claude Code

CI never ran on main: `push` ignored it and `pull_request` only tested a PR
against main as it stood at the PR's last push. It now runs on every pull
request whatever its base (stacked PRs included) and on main after each merge,
which also drops the duplicate push + pull_request run per PR push.

The build's one piece of build-time code, the health.json plugin, was neither
typechecked (esbuild strips vite.config.ts without checking it) nor checked by
output. tsconfig now includes vite.config.ts, and CI asserts the emitted
dist/health.json still honours the static-artifact contract.

Serving:
- public/serve.json turns off serve's default directory index, however serve
  is started on dist/. Pinned, with the existing 404-not-index.html rule, by a
  test that spawns serve with ecosystem.config.cjs's own args.
- nginx.conf.example: the server-level security headers reached no response,
  because every location sets its own add_header. They move to a snippet each
  location includes, with HSTS, and the page gets a Content-Security-Policy
  the bundle runs under with zero violations.
- Phaser is its own chunk, so an app-only deploy stops invalidating ~330 kB
  gzip of cached vendor code. App chunks keep Vite's 500 kB warning.
- The manifest no longer locks installed phones to landscape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant