diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cc0254..436766b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,4 +34,18 @@ jobs: # so it passes here without a .dev.vars (which is gitignored and never in CI). - run: pnpm -r test + # The web build hard-fails without VITE_API_BASE (see packages/web/vite.config.ts). + # Supply a throwaway value — CI only verifies the build compiles; admin/worker ignore it. - run: pnpm -r build + env: + VITE_API_BASE: https://example.invalid + + # Regression guard: the web build MUST refuse to build without VITE_API_BASE, so a fork + # can never ship an upload page that white-screens at load (no VITE_API_BASE in this step). + - name: web build must fail without VITE_API_BASE + run: | + if pnpm --filter @chippot/web build; then + echo "::error::web build succeeded without VITE_API_BASE — the fail-fast guard is broken" + exit 1 + fi + echo "OK: web build correctly refused to build without VITE_API_BASE" diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index 081c8d9..ea916f8 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -1,6 +1,16 @@ -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react"; -export default defineConfig({ - plugins: [react()], +// VITE_API_BASE is statically inlined at build time and points the upload page at the user's +// own worker. If it's missing, `vite build` still succeeds and emits a bundle that throws at +// load (white screen) — invisible to CI's `pnpm -r build`. Fail the BUILD instead so a fork +// finds out immediately. (dev is exempt: `vite dev` can run against a proxy/placeholder.) +export default defineConfig(({ command, mode }) => { + if (command === "build" && !loadEnv(mode, process.cwd(), "VITE_").VITE_API_BASE) { + throw new Error( + "VITE_API_BASE is required for the web build. Point it at your worker, e.g.\n" + + " VITE_API_BASE=https://chippot..workers.dev pnpm --filter @chippot/web build" + ); + } + return { plugins: [react()] }; }); diff --git a/packages/worker/test/apply-migrations.ts b/packages/worker/test/apply-migrations.ts index 9a9aedd..d092040 100644 --- a/packages/worker/test/apply-migrations.ts +++ b/packages/worker/test/apply-migrations.ts @@ -1,3 +1,9 @@ import { applyD1Migrations, env } from "cloudflare:test"; +// Scrub external secrets that a local `.dev.vars` would otherwise inject (CI has none), so the +// test baseline is identical on every machine. Tests that exercise outbound calls set their own +// dummy token + stub fetch; leaving a real token here would make those paths hit Discord for real +// locally while CI takes the no-send branch. Keep in sync with outbound-gated secrets. +delete (env as { DISCORD_BOT_TOKEN?: string }).DISCORD_BOT_TOKEN; + await applyD1Migrations(env.DB, env.TEST_MIGRATIONS); diff --git a/packages/worker/test/smoke.test.ts b/packages/worker/test/smoke.test.ts index 701369e..02f6cc0 100644 --- a/packages/worker/test/smoke.test.ts +++ b/packages/worker/test/smoke.test.ts @@ -11,4 +11,13 @@ describe("test harness", () => { expect(env.DB).toBeDefined(); expect(env.BUCKET).toBeDefined(); }); + + // A local `.dev.vars` (gitignored) supplies a REAL DISCORD_BOT_TOKEN, but CI has none. + // If that token leaked into the test baseline, any code path gated on it (e.g. + // billing/initiate → sendBillingOpened) would make a REAL Discord fetch locally while + // CI silently takes the no-send branch — tests would behave differently per machine. + // The setup file scrubs it; tests that exercise sending set their own dummy token. + it("does not leak external secrets from .dev.vars into the test baseline", () => { + expect(env.DISCORD_BOT_TOKEN).toBeUndefined(); + }); });