ci, plan: gate the committed web build in CI

This commit is contained in:
shum
2026-08-27 10:28:27 +00:00
parent b196a18d8a
commit 85a753f42f
2 changed files with 47 additions and 3 deletions
+39
View File
@@ -103,6 +103,45 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# ==================================
# Badge service web build check
# ==================================
# apps/simplex-badge-service/web/dist/ is committed and embedded into the
# service binary at compile time, so a change under web/src or web/assets
# without a rebuild ships stale JavaScript or a stale asset with a green build
# and green tests. Rebuild it here and fail when the result is not what is
# committed. Runs on every trigger of this workflow: it costs seconds, and its
# pull_request paths already cover apps/simplex-badge-service/**.
badge-web:
name: "badge service web build matches its sources"
runs-on: ubuntu-latest
steps:
- name: Clone project
uses: actions/checkout@v6
- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 24
- name: Build the checkout site
working-directory: apps/simplex-badge-service/web
run: npm ci && npm run build
- name: Check the committed build against the rebuilt one
run: |
# --porcelain, not `git diff --exit-code`: a file the build emits that
# was never `git add`ed is untracked, and `git diff` ignores it.
changed=$(git status --porcelain -- apps/simplex-badge-service/web/dist)
if [ -n "$changed" ]; then
echo "$changed"
echo "apps/simplex-badge-service/web/dist does not match its sources."
echo "Run 'npm ci && npm run build' in apps/simplex-badge-service/web and commit dist/."
exit 1
fi
# =========================
# Linux Build
# =========================
@@ -157,7 +157,7 @@ The two `-m` filters are needed because the badge tests live under two hspec pat
| D5 | URL prefill | D3 | ☐ |
| D6 | `POST /api/checkout`, provider interface, order creation | A4, B3, D0, D4 | ☐ |
| D7 | Pay button and checkout error states | D3, D6 | ☐ |
| D8 | CI check for the committed web build | D1 | ☐ |
| D8 | CI check for the committed web build | D1 | ☑ |
| E1 | Provider mock harness | A5 | ☐ |
| E2 | BTCPay client | A6, D6, E1 | ☐ |
| E3 | BTCPay webhook, settlement, code creation | B3, D0, D6, E2 | ☐ |
@@ -896,9 +896,11 @@ POST /api/checkout { priceId, offerId?, method: "card"|"btc"|"xmr" }
**Do:** `web/dist/` is committed and the Haskell build embeds it, so a change to `web/src` or `web/assets` without a rebuild ships stale JavaScript or a stale asset silently, with a green build and green tests. `.github/workflows/build.yml` already triggers on `apps/simplex-badge-service/**` but has no node step.
Add a job gated on `apps/simplex-badge-service/web/**` that runs `npm ci && npm run build` and fails if `git diff --exit-code apps/simplex-badge-service/web/dist` is non-empty.
Add a `badge-web` job to that workflow which checks out the repository, installs a pinned current node (`actions/setup-node`, node 24 — `build.mjs` needs a modern one; the *emit* is deterministic either way because `typescript` is pinned exactly in the lockfile), runs `npm ci && npm run build` in `apps/simplex-badge-service/web`, and fails when `git status --porcelain -- apps/simplex-badge-service/web/dist` is non-empty. `npm ci`, never `npm install`, which can float dependencies and rewrite the lockfile. No cache step: this installs one package.
**Verify:** Manual: edit a `.ts` file without rebuilding and confirm the job fails; rebuild and confirm it passes.
The job runs on every trigger of the workflow rather than being gated on `apps/simplex-badge-service/web/**` — `paths:` is a workflow trigger, not a per-job condition, and the workflow's `pull_request` paths already cover `apps/simplex-badge-service/**` (§9). `git status --porcelain`, not `git diff --exit-code`, because the latter is blind to a built file that was never `git add`ed (§9).
**Verify:** GitHub Actions cannot be run before merge, so run the job's own command sequence in a clean clone of HEAD instead: `npm ci && npm run build` in `apps/simplex-badge-service/web`, then `git status --porcelain -- apps/simplex-badge-service/web/dist`, which is empty on the current tree. Then prove both failure modes fail it: (a) change one word in `src/ui.ts` without committing a rebuild — the check reports ` M dist/ui.js` and exits 1; (b) add a file to `assets/`, rebuild, and do not `git add` it — the check reports `?? dist/<name>` and exits 1, where `git diff --exit-code` exits 0.
---
@@ -1487,6 +1489,9 @@ Append here when a step contradicts this plan: the step id, what was wrong, and
- **D1 — its Verify line was rewritten from a manual browser check to a mechanical one, and the browser half is now carried by D2's Verify.** The original line was "Manual: `npm ci && npm run build` … `npx tsc --noEmit` is clean", which under rule 4 made D1 unfalsifiable in CI and in any environment without a display. It now specifies the reproducible rebuild (which is what D8 gates), the token resolution in `dev.html`, the emitted relative import, and a `curl` of every URL the page references over `python3 -m http.server`. **Exactly four things were therefore not run when D1 landed, and none of them is claimed to work:** that `dev.html` renders at all; that the browser executes the `main.js` → `ui.js` module graph; that `../styles.css` is applied rather than merely served with a `text/css` header; and that a `file://` open fails where HTTP succeeds, which the step asserts and which was not tested in either direction. Under rule 10 a deferral needs the deferring step to own the assertions, so **D2's Verify line now names the first three explicitly** — D2 replaces `main.ts`, `ui.ts`, `styles.css` and `index.html` wholesale, and without that its implementer would read a blank page as their own markup rather than as D1's mechanism. The `file://` claim is carried untested; nothing in the plan depends on it beyond the instruction not to do it, which `dev.html`'s own banner repeats.
- **D1/D8 — `git diff --exit-code -- dist` does not see untracked files, so the staleness gate has a blind spot.** The check now sits in D1's Verify line and is what D8 is to gate CI on. `dist/` is wiped and rebuilt every run, so a *deleted* module or asset is caught: the file vanishes from the working tree and the diff reports it. But a *new* asset that was built and never `git add`ed is untracked, and `git diff` ignores untracked paths entirely — CI would run `npm ci && npm run build`, produce a file that is not in the repository, and report the tree clean. The service would then embed a `dist/` missing that asset while `index.html` carries its token, which under D4's rule fails at **startup**, not in CI. **D8 must assert on untracked files too** — `git status --porcelain apps/simplex-badge-service/web/dist` being empty covers both directions, where `git diff --exit-code` covers only one.
- **D1/D4 — a nested asset gets a served-set key no token can name, and the two resolvers must agree on this.** `dist/` is walked recursively, so an asset at `assets/img/x.svg` enters the served set keyed `img/x.svg`, while the token pattern is `@@([\w.-]+)@@` and does not accept `/`. Nothing can reference such a file, and the mismatch surfaces as the ordinary "token names nothing" build failure rather than as anything explaining itself. No asset is nested today and none is planned — D2's two logos and E5's encoder are all flat — so this is recorded rather than fixed. **D4 implements the same resolver server-side over `embedDir`'s `[(FilePath, ByteString)]`, whose keys are also relative paths**, and must use the same key and the same token charset; if it accepts `/` in a token while the build script does not, or keys its set differently, the two resolvers diverge on the first nested asset and the page that builds cleanly fails at startup. Whichever step first needs a subdirectory decides for both, in one place.
- **D8 — the step text's `git diff --exit-code` was implemented as `git status --porcelain`, per the D1/D8 entry above.** The blind spot was already recorded but the step text still named the blind command; it now names the porcelain one. This is not theoretical: in the clean clone used to verify the job, building a new asset into `dist/` without `git add`ing it leaves `git diff --exit-code -- dist` **exiting 0** while the porcelain check exits 1 — the gate as the plan first wrote it would have gone green on precisely the failure it exists to catch, and that failure surfaces at D4 as a *startup* error in the service. The untracked case includes a nested asset, whose whole directory `git status` collapses to one `?? …/dist/img/` line — still non-empty, so still a failure.
- **D8 — "a job gated on `apps/simplex-badge-service/web/**`" is not expressible; the job runs on every trigger of `build.yml` instead.** GitHub Actions `paths:` filters are a *workflow* trigger, not a per-job condition, so the only ways to gate a single job are a separate workflow file with its own `paths:` or a third-party changed-files action. Neither was taken. `build.yml` is this repository's one PR gate and its `pull_request` paths already list `apps/simplex-badge-service/**` (`:19`), so a PR touching `web/` already runs this workflow and the gating would buy nothing there; `web.yml`, the only `paths:`-triggered workflow here, is a gh-pages *deploy* pipeline, not a check, so following its shape would be copying a different kind of thing. On pushes to `master`/`stable` and on tags, where `build.yml` has no `paths` filter, running unconditionally is what is wanted — a merge can leave `dist/` inconsistent with `src/` while touching neither, and a path-filtered workflow would skip exactly that commit. The cost is `npm ci` of one package plus `tsc`, seconds, in parallel with builds that take tens of minutes. A separate workflow would also add a second path list to keep in step with the first and a second required-check name; a `paths`-filtered check that is marked required leaves PRs that do not touch those paths pending forever.
- **D8 — the step's Verify was manual and unrunnable before merge; it is now the same command sequence run locally.** "Edit a `.ts` and confirm the job fails" cannot be executed before the workflow is on the default branch. The line now specifies a clean clone of HEAD, the job's own commands in it, and both failure modes with the exact output each produces — which is what was run. What remains unverified is only what no local run can cover: that GitHub resolves `actions/checkout@v6` and `actions/setup-node@v6` and provisions node 24 on `ubuntu-latest`. The first CI run on this branch confirms that.
## 10. End-to-end verification