chore(ci): enforce pro-test bundle freshness — local hook + CI backstop#3229
Conversation
…taleness public/pro/ is committed to the repo and served verbatim by Vercel. The root build script only runs the main app's vite build — it does NOT run pro-test's build. So any PR that changes pro-test/src/** without manually running `cd pro-test && npm run build` and committing the regenerated chunks ships to production with a stale bundle. This footgun just cost us: PR #3227 fixed the Clerk "not loaded with Ui components" sign-in bug in source, merged, deployed — and the live site still threw the error because the committed chunk under public/pro/assets/ was the pre-fix build. PR #3228 fix-forwarded by rebuilding. Two-layer enforcement so it doesn't happen again: 1. .husky/pre-push — mirrors the existing proto freshness block. If pro-test/ changed vs origin/main, rebuild and `git diff --exit-code public/pro/`. Blocks the push with a clear message if the bundle is stale or untracked files appear. 2. .github/workflows/pro-bundle-freshness.yml — CI backstop on any PR touching pro-test/** or public/pro/**. Runs `npm ci + npm run build` in pro-test and fails the check if the working tree shows any diff or untracked files under public/pro/. Required before merge, so bypassing the local hook still can't land a stale bundle. Note: the hook's diff-against-origin/main check means it skips the build when pushing a branch that already matches main on pro-test/ (e.g. fix-forward branches that only touch public/pro/). CI covers that case via its public/pro/** path filter.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Greptile SummaryThis PR adds a two-layer guard to prevent stale Confidence Score: 5/5Safe to merge — both layers work correctly; the two findings are minor usability and reliability polish that don't affect correctness of the guard itself All findings are P2. The core logic (diff-based staleness detection, untracked-files check, path-filtered CI trigger) is sound. The stdout-suppression and stale-node_modules issues are real but non-blocking — the guard still catches the primary failure mode described in the PR. .husky/pre-push — two minor polish items around build output visibility and dep freshness Important Files Changed
Flowchart%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[git push] --> B{pre-push hook}
B --> C{pro-test/ changed\nvs origin/main?}
C -- No --> D[Skip — no pro-test changes]
C -- Yes --> E{node_modules\nexists?}
E -- No --> F[npm install --prefer-offline]
E -- Yes --> G[Use existing node_modules]
F --> H[cd pro-test && npm run build]
G --> H
H -- fail --> I[❌ Block push: pro-test build failed]
H -- ok --> J{git diff public/pro/ dirty?}
J -- dirty --> K[❌ Block push: public/pro/ is stale]
J -- clean --> L{Untracked files in public/pro/?}
L -- yes --> M[❌ Block push: uncommitted bundle files]
L -- no --> N[✅ Push proceeds]
subgraph CI [CI Backstop — pro-bundle-freshness.yml]
O[PR opened/updated] --> P{Touches pro-test/** or public/pro/**?}
P -- No --> Q[Workflow skipped]
P -- Yes --> R[actions/checkout + setup-node@22]
R --> S[npm ci]
S --> T[npm run build]
T --> U{git diff public/pro/ dirty?}
U -- dirty --> V[❌ CI fails: public/pro/ stale]
U -- clean --> W{Untracked files?}
W -- yes --> X[❌ CI fails: uncommitted bundle files]
W -- no --> Y[✅ CI passes]
end
Reviews (1): Last reviewed commit: "chore(ci): enforce pro-test bundle fresh..." | Re-trigger Greptile |
| (cd pro-test && npm run build >/dev/null) || { | ||
| echo "ERROR: pro-test build failed" | ||
| exit 1 | ||
| } |
There was a problem hiding this comment.
Build stdout silenced, failures are opaque
>/dev/null discards all stdout from the vite build. If the build fails, the developer only sees "ERROR: pro-test build failed" with no context about why — missing import, type error, OOM, etc. The proto-freshness check directly above (make generate) shows full output. CI also shows full build output. Consider removing >/dev/null or redirecting only to a temp log file printed on failure, to keep parity with the CI experience.
| (cd pro-test && npm run build >/dev/null) || { | |
| echo "ERROR: pro-test build failed" | |
| exit 1 | |
| } | |
| (cd pro-test && npm run build) || { |
| if [ ! -d pro-test/node_modules ]; then | ||
| echo " Installing pro-test deps..." | ||
| (cd pro-test && npm install --prefer-offline) || exit 1 | ||
| fi |
There was a problem hiding this comment.
Stale
node_modules can silently diverge from CI
The hook only installs if pro-test/node_modules is absent. If package.json or package-lock.json changed since the last install (e.g. a new Clerk dep), the local build runs against the old dependency tree, potentially producing different chunk hashes than npm ci in CI. The hook could pass locally while CI fails — or vice versa — without any visible indication. Since the check for a lockfile change already exists elsewhere in this hook (line 39–48 for scripts/), a similar lockfile-drift check here, or replacing npm install with npm ci, would keep local and CI builds in sync.
The first version of this hook used `git diff --name-only origin/main -- pro-test/`, which compares the WORKING TREE to origin/main. That fires on unstaged local pro-test/ scratch edits and blocks pushing unrelated branches purely because of dirty checkout state. Switch to `$CHANGED_FILES` (computed earlier at line 77 from `git diff origin/main...HEAD`), which scopes the check to commits on the branch being pushed. This matches the convention the test-runner gates already use (lines 93-97). Also honor `$RUN_ALL` as the safety fallback when the branch delta can't be computed.
|
Good catch, fixed in `9210b1a71`. The check now uses `$CHANGED_FILES` (the branch delta computed at line 77 from `git diff origin/main...HEAD`), matching the convention the test-runner gates at lines 93–97 already use. Also honors `$RUN_ALL` as the safety fallback when the branch delta can't be computed. ```diff
Now an unrelated branch with dirty pro-test/ scratch edits in the worktree will skip the check entirely instead of triggering a slow rebuild. The check only fires when the branch's actual commits touch `pro-test/`. (Pre-existing note: the proto-freshness check immediately above this one — lines 147–178 — has the same worktree-vs-branch bug. Out of scope for this PR, but worth a follow-up to convert it to the same `$CHANGED_FILES` form for consistency.) |
The first scoping fix used `^pro-test/` only, but the CI workflow keys off both `pro-test/**` AND `public/pro/**`. That left a gap: a bundle-only PR (e.g. a fix-forward rebuild like #3228, or a hand-edit to a committed asset) skipped the local check entirely while CI would still validate it. The hook and CI are now consistent. Trigger condition: `^(pro-test|public/pro)/` — the rebuild + diff check now fires whenever the branch delta touches either side of the source/artifact pair, matching the CI workflow's path filter.
|
Fair, fixed in `463f073f4`. Hook trigger now matches the workflow path filter exactly: ```diff
Now a bundle-only PR (fix-forward rebuild like #3228, or a hand-edit to a committed asset) gets the same local rebuild + diff verification that CI runs. Behavior is identical for genuine source PRs — they hit the `pro-test/` arm — but the `public/pro/` arm closes the gap you flagged. Skipped-message updated to "no pro-test/ or public/pro/ changes in branch" to reflect both triggers. |
The actual root cause behind the "Clerk was not loaded with Ui
components" sign-in failure on /pro is NOT the import path — it's
that pro-test was on @clerk/clerk-js v6.4.0 while the main app
(which works fine) is on v5.125.7.
Clerk v6 fundamentally changed `clerk.load()`: the UI controller
is no longer auto-mounted by default. Both `@clerk/clerk-js` (the
default v6 entry) and `@clerk/clerk-js/no-rhc` (the bundled-UI
variant) expect the caller to either:
- load Clerk's UI bundle from CDN and pass `window.__internal_ClerkUICtor`
to `clerk.load({ ui: { ClerkUI } })`, or
- manually wire up `clerkUICtor`.
That's why my earlier "switch to no-rhc" fix (PR #3227 + #3228)
didn't actually unbreak production — both v6 variants throw the same
assertion. The error stack on the deployed bundle confirmed it:
`assertComponentsReady` from `clerk.no-rhc-UeQvd9Xf.js`.
Fix: pin pro-test to `@clerk/clerk-js@^5.125.7` to match the main
app's working version. v5 still auto-mounts UI on `clerk.load()` —
no extra wiring needed. The plain `import { Clerk } from '@clerk/clerk-js'`
pattern (which the main app uses verbatim and which pro-test had
before #3227) just works under v5.
Verification of the rebuilt bundle (chunk: clerk-PNSFEZs8.js):
- 3.05 MB (matches main app's clerk-DC7Q2aDh.js: 3.05 MB)
- 44 occurrences of mountComponent (matches main: 44)
- 3 occurrences of SignInComponent (matches main: 3)
- 0 occurrences of "Clerk was not loaded with Ui" (the assertion
error string is absent; UI is unconditionally mounted)
Includes the rebuilt public/pro/ artifacts so this fix is actually
deployed (PR #3229's CI check will catch any future PR that touches
pro-test/src without rebuilding).
…3232) The actual root cause behind the "Clerk was not loaded with Ui components" sign-in failure on /pro is NOT the import path — it's that pro-test was on @clerk/clerk-js v6.4.0 while the main app (which works fine) is on v5.125.7. Clerk v6 fundamentally changed `clerk.load()`: the UI controller is no longer auto-mounted by default. Both `@clerk/clerk-js` (the default v6 entry) and `@clerk/clerk-js/no-rhc` (the bundled-UI variant) expect the caller to either: - load Clerk's UI bundle from CDN and pass `window.__internal_ClerkUICtor` to `clerk.load({ ui: { ClerkUI } })`, or - manually wire up `clerkUICtor`. That's why my earlier "switch to no-rhc" fix (PR #3227 + #3228) didn't actually unbreak production — both v6 variants throw the same assertion. The error stack on the deployed bundle confirmed it: `assertComponentsReady` from `clerk.no-rhc-UeQvd9Xf.js`. Fix: pin pro-test to `@clerk/clerk-js@^5.125.7` to match the main app's working version. v5 still auto-mounts UI on `clerk.load()` — no extra wiring needed. The plain `import { Clerk } from '@clerk/clerk-js'` pattern (which the main app uses verbatim and which pro-test had before #3227) just works under v5. Verification of the rebuilt bundle (chunk: clerk-PNSFEZs8.js): - 3.05 MB (matches main app's clerk-DC7Q2aDh.js: 3.05 MB) - 44 occurrences of mountComponent (matches main: 44) - 3 occurrences of SignInComponent (matches main: 3) - 0 occurrences of "Clerk was not loaded with Ui" (the assertion error string is absent; UI is unconditionally mounted) Includes the rebuilt public/pro/ artifacts so this fix is actually deployed (PR #3229's CI check will catch any future PR that touches pro-test/src without rebuilding).
Reviewer flagged that the first iteration's `window.top !== window`
check was too broad. The repo explicitly markets "Embeddable iframe
panels" as an Enterprise feature
(pro-test/src/locales/en.json: whiteLabelDesc), so legitimate
customer embeds must keep firing premium RPCs normally. Only the /pro
marketing preview — which is known-anonymous and generates expected
401 noise — should short-circuit.
Fix: replace the blanket iframe check with a unique marker that only
/pro's preview iframe carries.
- pro-test/src/App.tsx: iframe src switched from
`?alert=false` (dead param, unused in main app) to
`?embed=pro-preview`. Rebuilt public/pro/ to ship the change.
- src/utils/embedded-preview.ts: two-gate check now. Gate 1 still
requires `window.top !== window` so the marker leaking into a
top-level URL doesn't disable premium RPCs for the top-level app.
Gate 2 requires `?embed=pro-preview` in location.search so only
the known embedder matches. Enterprise white-label embeds without
this marker behave exactly like a top-level visit.
Same three premium fetchers + the one country-intel path still gate
on IS_EMBEDDED_PREVIEW; the semantic change is purely in how the
flag is computed.
Per PR #3229 / #3228 lesson, the pro-test rebuild ships in the same
PR as the source change — public/pro/assets/index-*.js and index.html
reflect the new iframe src.
…review iframe (#3235) * fix(preview): skip premium RPCs when main app runs inside /pro preview iframe pro-test/src/App.tsx embeds the full main app as a "live preview" via <iframe src="https://worldmonitor.app?alert=false" sandbox="...">. The iframe boots an anonymous main-app session, which fires premium RPCs (get-regional-snapshot, get-tariff-trends, list-comtrade-flows, and on country-click the fetchProSections batch) with no Clerk bearer available. Every call 401s, the circuit breakers catch and fall through to empty fallbacks (so the preview renders fine), but the 401s surface on the PARENT /pro page's DevTools console and Sentry because `sandbox` includes `allow-same-origin`. Net effect: /pro pricing page shows a flood of fake-looking errors that cost us a session of debugging to trace back to the iframe. PR #3233's premiumFetch swap didn't help here (there's simply no token to inject for an anonymous iframe). Introduce `src/utils/embedded-preview.ts::IS_EMBEDDED_PREVIEW`, a module-level boolean evaluated once at load from `window.top !== window` (with try/catch for cross-origin sandboxes), and short-circuit three init-time premium entry points when true: - RegionalIntelligenceBoard.loadCurrent → renderEmpty() - fetchTariffTrends → return emptyTariffs - fetchComtradeFlows → return emptyComtrade Plus one defensive gate in country-intel.fetchProSections for the case a user clicks a country inside the iframe preview. Each gate returns the exact same empty fallback the breaker would have produced after a 401, so visual behavior is unchanged — the preview iframe still shows the dashboard layout with empty premium panels, just without the network request and its console/Sentry trail. Live-tab /pro page should now see zero 401s from regional-snapshot / tariff-trends / comtrade-flows on load. * fix(preview): narrow iframe gate to ?embed=pro-preview marker only Reviewer flagged that the first iteration's `window.top !== window` check was too broad. The repo explicitly markets "Embeddable iframe panels" as an Enterprise feature (pro-test/src/locales/en.json: whiteLabelDesc), so legitimate customer embeds must keep firing premium RPCs normally. Only the /pro marketing preview — which is known-anonymous and generates expected 401 noise — should short-circuit. Fix: replace the blanket iframe check with a unique marker that only /pro's preview iframe carries. - pro-test/src/App.tsx: iframe src switched from `?alert=false` (dead param, unused in main app) to `?embed=pro-preview`. Rebuilt public/pro/ to ship the change. - src/utils/embedded-preview.ts: two-gate check now. Gate 1 still requires `window.top !== window` so the marker leaking into a top-level URL doesn't disable premium RPCs for the top-level app. Gate 2 requires `?embed=pro-preview` in location.search so only the known embedder matches. Enterprise white-label embeds without this marker behave exactly like a top-level visit. Same three premium fetchers + the one country-intel path still gate on IS_EMBEDDED_PREVIEW; the semantic change is purely in how the flag is computed. Per PR #3229 / #3228 lesson, the pro-test rebuild ships in the same PR as the source change — public/pro/assets/index-*.js and index.html reflect the new iframe src.
…users (#3250) * fix(pro-marketing): reflect auth state in nav, hide pro banner for pro users Two related signed-in-experience bugs caught by the user during the post-purchase flow: 1. /pro Navbar's SIGN IN button never reacted to auth state. The component was a static const Navbar = () => <nav>...</nav>; with no Clerk subscription, so signing in left the SIGN IN button in place even though the user was authenticated. 2. The "Pro is launched — Upgrade to Pro" announcement banner on the main app showed for ALL visitors including paying Pro subscribers. Pitching upgrade to a customer who already paid is a small but real annoyance, and it stays sticky for 7 days via the localStorage dismiss key — so a returning paying user dismisses it once and then never sees the (genuinely useful) banner again if they later downgrade. ## Changes ### pro-test/src/App.tsx — useClerkUser hook + ClerkUserButton - New useClerkUser() hook subscribes to Clerk via clerk.addListener and returns { user, isLoaded } so any component can react to auth changes (sign-in, sign-out, account switch). - New ClerkUserButton component mounts Clerk's native UserButton widget (avatar + dropdown with profile/sign-out) into a div via clerk.mountUserButton — inherits the existing dark-theme appearance options from services/checkout.ts::ensureClerk. - Navbar swaps SIGN IN button for ClerkUserButton when user is signed in. Slot is intentionally empty during isLoaded=false to avoid a SIGN IN → avatar flicker for returning users. - Hero hides its redundant SIGN IN CTA when signed in; collapses to just "Choose Plan" which is the relevant action for returning users. - Public/pro/ rebuilt to ship the change (per PR #3229's bundle- freshness rule). ### src/components/ProBanner.ts — premium-aware show + reactive auto-hide - showProBanner returns early if hasPremiumAccess() — same authoritative signal used by the frontend's panel-gating layer (unions API key, tester key, Clerk pro role, AND Convex Dodo entitlement). - onEntitlementChange listener auto-dismisses the banner if a Convex snapshot arrives mid-session that flips the user to premium (e.g. Dodo webhook lands while they're sitting on the dashboard). Does NOT write the dismiss timestamp, so the banner reappears correctly if they later downgrade. ## Test plan ### pro-test (sign-in UI) - [ ] Anonymous user loads /pro → SIGN IN button visible in nav. - [ ] Click SIGN IN, complete Clerk modal → button replaced with Clerk's UserButton avatar dropdown. - [ ] Open dropdown, click Sign Out → reverts to SIGN IN button. - [ ] Hard reload as signed-in user → SIGN IN button never flashes; avatar appears once Clerk loads. ### main app (banner gating) - [ ] Anonymous user loads / → "Pro is launched" banner shows. - [ ] Click ✕ to dismiss → banner stays dismissed for 7 days (existing behavior preserved). - [ ] Pro user (active Convex entitlement) loads / → banner does NOT appear, regardless of dismiss state. - [ ] Free user opens /, then completes checkout in another tab and Convex publishes the entitlement snapshot → banner auto-hides in the dashboard tab without reload. - [ ] Pro user whose subscription lapses (validUntil < now) → banner reappears on next page load, since dismiss timestamp wasn't written by the entitlement-change auto-hide. * fix(pro-banner): symmetric show/hide on entitlement change Reviewer caught that the previous iteration only handled the upgrade direction (premium snapshot → hide banner) but never re-showed the banner on a downgrade. App.ts calls showProBanner() once at init, so without a symmetric show path, a session that started premium and then lost entitlement (cancellation, billing grace expiry, plan downgrade for the same user) would stay banner-less for the rest of the SPA session — until a full reload re-ran App.ts init. Net effect of the bug: the comment claiming "the banner reappears correctly if they later downgrade or the entitlement lapses" was false in practice for any in-tab transition. Two changes: 1. Cache the container on every showProBanner() call, including the early-return paths. App.ts always calls showProBanner() once at init regardless of premium state, so this guarantees the listener has the container reference even when the initial mount was skipped (premium user, dismissed, in iframe). 2. Make onEntitlementChange handler symmetric: - premium snapshot + visible → hide (existing behavior) - non-premium snapshot + not visible + cached container + not dismissed + not in iframe → re-mount via showProBanner The non-premium re-mount goes through showProBanner() so it gets the same gate checks as the initial path (isDismissed, iframe, premium). We can never surface a banner the user has already explicitly ✕'d this week. Edge cases handled: - User starts premium, no banner shown, downgrades mid-session → listener fires, premium false, no bannerEl, container cached, not dismissed → showProBanner mounts banner ✓ - User starts free, sees banner, upgrades mid-session → listener fires, premium true, bannerEl present → fade out ✓ - User starts free, dismisses banner, upgrades, downgrades → listener fires on downgrade, premium false, no bannerEl, container cached, isDismissed=true → showProBanner returns early ✓ - User starts free, banner showing, multiple entitlement snapshots arrive without state change → premium=false && bannerEl present, neither branch fires, idempotent no-op ✓ * fix(pro-banner): defer initial mount while entitlement is loading Greptile P1 round-2: hasPremiumAccess() at line 48 reads isEntitled() synchronously, but the Convex entitlement subscription is fired non-awaited at App.ts:868 (`void initEntitlementSubscription()`). showProBanner() runs at App.ts:923 during init Phase 1, before the first Convex snapshot arrives. So a Convex-only paying user (Clerk role 'free' + Dodo entitlement tier=1) sees this sequence: t=0 init runs → hasPremiumAccess() === false (isEntitled() reads currentState===null) → "Upgrade to Pro" banner mounts t=~1s Convex snapshot arrives → onEntitlementChange fires → my listener detects premium=true && bannerEl !== null → fade out That's a 1+ second flash of "you should upgrade!" content for someone who has already paid. Worst case is closer to ~10s on a cold-start Convex client, which is much worse — looks like the upgrade pitch is the actual UI. Defer the initial mount when (1) the user is signed in (so they plausibly have a Convex entitlement) AND (2) the entitlement state hasn't loaded yet (currentState === null). The existing onEntitlementChange listener will mount it later if the first snapshot confirms the user is actually free. Two reasons this is gated on "signed in": - Anonymous users will never have a Convex entitlement, so deferring would mean the banner NEVER mounts for them. Bad regression: anon visitors are the highest-value audience for the upgrade pitch. - For signed-in users, the worst case if no entitlement EVER arrives is the banner stays absent — which is identical to a paying user's correct state, so it fails-closed safely. Edge case behavior: - Anonymous user: no Clerk session → first condition false → banner mounts immediately ✓ - Signed-in free user with first snapshot pre-loaded somehow: second condition false → banner mounts immediately ✓ - Signed-in user, snapshot pending: deferred → listener mounts on first snapshot if user turns out free ✓ - Signed-in user, snapshot pending, user turns out premium: never mounted ✓ (the desired path) - Signed-in user, snapshot pending, never arrives (Convex outage): banner never shows → see above, this fails-closed safely
Why
`public/pro/` is committed to the repo and served verbatim by Vercel. The root build script only runs the main app's vite build — it does NOT run `pro-test/`'s build. So any PR that changes `pro-test/src/**` without manually running `cd pro-test && npm run build` and committing the regenerated chunks ships to production with a stale bundle.
This footgun just cost us:
Without enforcement, this happens again the next time someone touches the pricing page, checkout, or any other pro-test surface.
What
Two-layer guard, mirroring the proto-freshness pattern that already exists in the same hook (`.husky/pre-push:146-178`):
1. `.husky/pre-push` (local fast feedback)
If `pro-test/` changed vs `origin/main`:
2. `.github/workflows/pro-bundle-freshness.yml` (CI backstop)
On any PR touching `pro-test/` or `public/pro/`:
This catches PRs even if the author bypassed the local hook (`--no-verify`, different machine, etc).
Test plan
Notes