Skip to content

Latest commit

 

History

History
598 lines (485 loc) · 32.6 KB

File metadata and controls

598 lines (485 loc) · 32.6 KB

HackHQ Web

The web frontend for HackHQ — a browsable interface for the hackathon listings maintained in this repository.

It's a Next.js (App Router) app with a 3D globe, card deck, and member tracker, plus a legacy searchable directory at /hackathons.

What it does

  • Home (/) — hero, stats, and entry points into the globe and deck.
  • Globe (/globe) — 3D Mapbox map with status-colored markers.
  • Deck (/deck) — flip through hackathons as tactile cards or a dense list.
  • My HackHQ (/my) — protected personal tracker pipeline (optional Clerk sign-in).
  • Resources (/resources) — a stage-by-stage field guide with curated links.
  • All hackathons (/hackathons) — legacy README-driven search and filters.

How it works

Page content is not fetched from a database at runtime. Listing data lives in the repo and is read from disk when pages are generated, which is why the globe, the deck and /hackathons render with no database configured at all.

There is one exception, and it is a real one: the signed-in tracker. /api/tracker reads and writes public.user_hackathons in Supabase on every request — see Tracker sync modes below. Without Clerk or without Supabase it degrades to browser-local storage, so a deployment with neither genuinely has no runtime database; a configured one does.

The schema's source of truth is supabase/migrations/, not Drizzle. db/schema.ts mirrors it for types only and is not a migration authority — see Supabase schema.

Data sources

Route(s) Loader Source file
/, /deck, /globe, /my loadHackathons() in lib/listings.ts ../.github/scripts/listings.json
/hackathons loadSiteData() in lib/parse-readme.ts ../README.md (table + stats banner)
/resources none — imported directly lib/resources.ts (stages, links, teaser copy)

listings.json is the source of truth for the main HackHQ experience. parse-readme.ts still powers the legacy /hackathons page, which parses the README table between <!-- HACKATHONS_TABLE_START --> and <!-- HACKATHONS_TABLE_END -->.

Supabase schema

supabase/migrations/ is the single source of truth for this database. Those files are what has actually been applied, and they carry the parts Drizzle cannot represent at all: row level security, the column-level GRANTs that withhold submitted_by from anon and authenticated, the two BEFORE UPDATE triggers, and the auth.jwt() default on user_hackathons.user_id.

db/schema.ts is types only — not a migration authority. It is the shape the app compiles against, annotated column by column with the migration that defines it. Schema changes are written as SQL in supabase/migrations/ and applied by hand through the Supabase SQL Editor (this repo has no Supabase CLI and no MCP configured). The procedure, including the snapshot to take first and the self-aborting invariant to wrap the apply in, is docs/runbooks/apply-migration.md.

Two Drizzle scripts remain, and both only read the live database. Each needs DATABASE_URL (or SUPABASE_DATABASE_URL) set to the Supabase Postgres connection string:

npm run db:pull    # introspect the live schema
npm run db:studio  # browse it

db:push, db:generate and db:migrate are gone on purpose. A migration generated from db/schema.ts models no RLS, no grants and no triggers, so applying one does not just miss the security model — it deletes it. Dropping origin or submitted_by cascades all four policies on public.hackathons, while ENABLE ROW LEVEL SECURITY survives, and a table with RLS on and no policies is default deny: the site reads nothing and the hourly sync writes nothing.

Two Drizzle artefacts are superseded and should not be trusted:

  • drizzle/0000_add_hackathon_event_dates.sql — its startDate/endDate columns are already in the Supabase baseline at ../supabase/migrations/20260722141955_baseline_hackathons.sql:27-28. There is nothing left for it to add, and it was never part of the applied chain.
  • drizzle/meta/0000_snapshot.json — it describes only public.hackathons, records "isRLSEnabled": false with an empty policies map, and does not mention user_hackathons at all. It is the leftover state file of that one generated migration, not a description of the database.

.github/scripts/test_schema_drift.py fails CI when db/schema.ts and supabase/migrations/ stop agreeing, so the types cannot quietly rot away from the SQL that defines them.

The Supabase mirror is still seeded by ../.github/scripts/seed_supabase.py on an hourly cron; see ../supabase/migrations/README.md for the two write paths and why the sync never re-owns a user's row.

Putting a listing on the globe

The globe can only render a listing it has coordinates for. The table lives in .github/scripts/geocodes.json and is read by two things that must never disagree: lib/geo.ts (the site) and .github/scripts/check_geo_coverage.py (the listing automation).

Lookups normalize first — case, whitespace, and a trailing country are all ignored, so Toronto, ON, Toronto, ON, Canada, and Toronto, Canada all resolve to one Toronto rather than needing three entries.

A listing in a city we can't place is reported, never dropped in silence (#111):

Path What happens
Pull request lib/geo-coverage.test.ts fails CI, naming the location
Automated add (issue → approved) The workflow comments on the issue naming the location. It does not block the add — those jobs push to main with the default GITHUB_TOKEN, so no Web CI run is created for them
Either way loadHackathons() warns, and the globe states how many listings it isn't showing

To fix a report, add the location to coordinates in geocodes.json — or to unmappable if it genuinely has no place on a map (e.g. TBA). Virtual listings are excluded from the map on purpose and never trip the check.

Render model

Pages are prerendered, then revalidated hourly (ISR): every data-backed page exports revalidate = 3600, so the server re-runs its loader in the background at most once an hour.

Be precise about what that refreshes. scripts/prepare-repo-data.mjs copies the repo-root files (README.md, listings.json, geocodes.json) into lib/generated/ at build time, and the loaders import them — so the data is frozen into the deployment at build, and a revalidation re-runs the loader over that deployed copy, not whatever is on main now. (No request-time filesystem read remains, which is what keeps the app portable across hosts — see Deployment.)

Changes without a rebuild Needs a new build + deploy
Deadline-derived state — "closing soon" flags, day counts, anything computed from the current date The listings themselves — editing listings.json, README.md, or geocodes.json

The build also publishes the snapshot it was made from at /site-data/listings.json and the commit it came from at /site-data/build.json (static files written by prepare-repo-data.mjs into public/site-data/, gitignored). They exist so that "is the site serving what the repository says?" is answered by comparing ids, not by searching page HTML: the deploy workflow reads build.json before it records a deploy as shipped, and check_site_freshness.py reads both.

That is exactly what the hour is for (#47): those flags are derived from today, so a page prerendered last week would otherwise keep serving last week's countdown until someone redeployed.

Route Production render mode
/, /deck, /globe, /my, /hackathons Prerendered, ISR — revalidate = 3600
/resources Prerendered, no revalidation — content is compiled-in constants, not repo data
/auth/[[...auth]] Dynamic — rendered per request

next build prints this: the ISR routes carry a Revalidate value of 1h, /resources carries none, and /auth/[[...auth]] is marked ƒ (Dynamic).

Development (npm run dev) snapshots the data once, when predev runs scripts/prepare-repo-data.mjs. Editing listings.json or README.md while the dev server is running does not show up on refresh — regenerate the snapshot with npm run prepare-data (or restart npm run dev). Editing a component still hot-reloads as usual.

Assets

Images referenced in the README (e.g. assets/hackathons-banner.svg) are resolved by resolveAssetSrc() in lib/parse-readme.ts:

  1. Local first — if the file is in the build-time asset manifest (i.e. it exists under ../assets/), it's served as a static file from public/repo-assets/.
  2. Remote fallback — otherwise it falls back to the file on main via raw.githubusercontent.com.

public/repo-assets/ is generated, not committed. scripts/copy-repo-assets.mjs copies ../assets/ into it, and both npm run dev (via predev) and npm run build run that script first — so the files are in place before Next.js starts. Run it on its own with npm run copy-assets.

Project structure

web/
├── app/
│   ├── page.tsx                       # Home; loadHackathons() + HomeClient
│   ├── globe/page.tsx                 # 3D globe
│   ├── deck/page.tsx                  # Card deck
│   ├── my/page.tsx                    # Protected member tracker hub
│   ├── resources/page.tsx             # Hackathon field guide
│   ├── auth/[[...auth]]/page.tsx      # Clerk sign-in/sign-up
│   ├── hackathons/page.tsx            # Legacy README browser
│   └── layout.tsx                     # Root layout, fonts, optional ClerkProvider
├── components/
│   ├── hq/                            # Current HackHQ UI (globe, deck, nav, …)
│   │   ├── nav.tsx                    # Nav pill; inline links at md and up
│   │   ├── mobile-menu.tsx            # The same sections below 768px
│   │   ├── resources.tsx              # /resources page sections
│   │   ├── stage-jump-nav.tsx         # Sticky stage rail; publishes its clearance
│   │   └── resources-teaser.tsx       # Home-page 2×2 teaser + resource-tile-card
│   └── legacy/                        # README-driven browser, gallery, cards
├── db/
│   └── schema.ts                      # Drizzle schema for the Supabase mirror
├── drizzle/
│   └── *.sql                          # Database migrations
├── lib/
│   ├── listings.ts                    # Reads listings.json, enriches for frontend
│   ├── nav.ts                         # Nav sections + active-route matching
│   ├── parse-readme.ts                # Parses ../README.md (legacy /hackathons)
│   ├── resources.ts                   # Field-guide stages, links, teaser tiles
│   ├── types-hq.ts                    # Hackathon types and display helpers
│   └── types.ts                       # Legacy opportunity types
├── drizzle.config.ts                  # Drizzle Kit config
├── open-next.config.ts                # OpenNext adapter — Cloudflare, see Deployment
├── wrangler.jsonc                     # Cloudflare Workers config (nodejs_compat)
└── middleware.ts                      # Clerk auth (Edge; see Deployment for why not proxy.ts)

Getting started

Requires Node.js >= 20.9.0.

Run from the web/ directory so that ../.github/scripts/listings.json, ../README.md, and ../assets/ resolve correctly.

cd web
cp .env.example .env.local   # then fill in values (see below)
npm install
npm run dev

Open http://localhost:3000.

One-time: install the git hooks

Run once per clone, from anywhere in the repo:

git config core.hooksPath .githooks

.githooks/pre-commit refuses to commit any .env / .env.* file except .env.example. Without this config, git ignores the directory entirely. If the hook does not fire, it needs the executable bit (chmod +x .githooks/pre-commit).

It is a convenience, not the enforcement: .github/workflows/secrets-guard.yml applies the same rule to the pushed commits, plus a gitleaks scan of the full history, so --no-verify does not get a secret onto main. If something does leak, follow docs/runbooks/rotate-credentials.md — deleting the file in a later commit is not a fix.

Environment variables

Copy .env.example to .env.local (gitignored) and set the values you need.

Variable Required Used by If missing
NEXT_PUBLIC_MAPBOX_TOKEN For globe components/hq/globe-map.tsx Globe shows a placeholder instead of the Mapbox map
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY For auth app/layout.tsx, app/my/page.tsx, middleware.ts Site runs without Clerk; /my shows setup instructions and /auth/* redirects to /my
CLERK_SECRET_KEY For auth app/my/page.tsx, middleware.ts Same as above — both Clerk keys are needed together
SUPABASE_URL For tracker sync lib/tracker-store.ts Tracker stays browser-local; /api/tracker reports synced: false
SUPABASE_ANON_KEY For tracker sync (token mode) lib/tracker-store.ts Tracker sync falls back to service mode if the service role key is set, otherwise stays browser-local. See Tracker sync modes
SUPABASE_SERVICE_ROLE_KEY For tracker sync (service mode) lib/tracker-store.ts Fine once token mode is live; without either key the tracker stays browser-local. Clerk must be configured in every case or there is no user to attribute a row to
SUPABASE_TRACKER_REQUIRE_RLS No lib/env.ts, lib/tracker-store.ts Set to 1 and tracker sync refuses service mode instead of falling back to the RLS-bypassing key. The flip's verification switch — see docs/runbooks/flip-token-mode.md
DATABASE_URL For DB scripts drizzle.config.ts npm run db:* commands fail fast before touching Supabase
NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN No instrumentation-client.ts Analytics is fully off — posthog-js is never downloaded
NEXT_PUBLIC_POSTHOG_KEY No lib/analytics.ts Legacy alias for NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN
NEXT_PUBLIC_POSTHOG_HOST No lib/analytics.ts Defaults to https://us.i.posthog.com

The two keys are the only Clerk variables you need. The auth routes (/auth/sign-in, /auth/sign-up) and the post-sign-in landing (/my) are pinned in middleware.ts and components/hq/auth-screen.tsx rather than read from NEXT_PUBLIC_CLERK_*_URL env vars — when those are unset, Clerk redirects to its hosted account portal instead of the app's own screens.

Clerk is optional. When both keys are set, ClerkProvider wraps the app, /my is protected in middleware.ts (signed-out visitors are redirected to /auth/sign-in), and users can sign in with Google, GitHub, or email/password. Without them, the tracker still works locally; nothing is persisted server-side.

To finish Clerk setup in the dashboard, enable Google and GitHub under social connections, and enable email/password under email authentication.

Tracker sync modes

lib/tracker-store.ts (behind /api/tracker) talks to Supabase in one of two modes, chosen by which key is present:

  • Token mode (preferred, SUPABASE_ANON_KEY): every request carries the signed-in caller's Clerk JWT, so queries run as Supabase's authenticated role and the RLS policies on public.user_hackathons (migration 20260725154500) enforce row ownership in Postgres. The upsert_tracker_row RPC is SECURITY INVOKER, so it inherits the same policies. A missing Clerk token is a hard error, never a fallback to the service role.
  • Service mode (legacy, SUPABASE_SERVICE_ROLE_KEY only): the service role bypasses RLS, so ownership is enforced in app code by the .eq("user_id", ...) filters and explicit user_id stamping in lib/tracker-store.ts. Those filters stay in token mode too, as a belt-and-braces layer under RLS.

Flipping a deployment to token mode takes three steps (issue #235):

  1. Clerk dashboard: create a JWT template named supabase whose claims include {"role": "authenticated"}.
  2. Supabase dashboard: register Clerk as a third-party auth provider (Authentication -> Sign In / Up -> Third Party Auth), so Supabase accepts Clerk-issued JWTs.
  3. Runtime env: set SUPABASE_ANON_KEY to the publishable key from Project Settings -> API. Token mode wins whenever it is set, so the service role key does not need to be removed for the flip itself.

Once token mode is verified in production, SUPABASE_SERVICE_ROLE_KEY can be removed from the runtime environment entirely: lib/tracker-store.ts is the only runtime code that reads it (the only other mentions in the repo are lib/env.ts reporting and these docs), so nothing else breaks without it.

Analytics

Web analytics (PostHog) is optional and off by default. To enable it, set NEXT_PUBLIC_POSTHOG_PROJECT_TOKEN (or the legacy NEXT_PUBLIC_POSTHOG_KEY) and optionally NEXT_PUBLIC_POSTHOG_HOST, then rebuild — without the token, the posthog-js chunk is never downloaded and no requests leave the browser.

The integration is deliberately cookieless and anonymous (instrumentation-client.ts plus lib/analytics.ts):

  • Collected: $pageview events for the initial page load and App Router client-side navigations. Anonymous visitors only.
  • Not collected: no cookies or localStorage (in-memory persistence only), no autocapture, no session recording, no surveys, no feature flags, no exception capture, no user identification or person profiles. Visitors with Do Not Track or Global Privacy Control enabled are never tracked at all.

Because nothing is stored on the device and events are anonymous aggregate stats, this configuration does not require a consent banner.

Scripts

Script Description
npm run dev Start the development server
npm run build Create a production build
npm run start Serve the production build
npm run lint Run ESLint
npm test Run the Vitest suite (what CI runs)
npm run copy-assets Refresh public/repo-assets/ from ../assets/
npm run prepare-data Regenerate lib/generated/ from the repo-root data
npm run preview Build with OpenNext and run the Worker locally
npm run deploy What CI runs; hand use is a last resort, see Deployment

dev, build, and test run copy-assets and/or prepare-data for you; you only need them directly after changing something under ../assets/ or the repo-root data files while a dev server is already running.

Production build

npm run build
npm run start

After changing listings.json or README.md, run a new build and deploy — the data is snapshotted into the deployment at build time, so hourly revalidation alone will not pick up an edit. Revalidation keeps date-derived state fresh between deploys; it does not fetch new content. See Render model.

Deployment

Production target: Cloudflare Workers — the hackhq Worker, serving hacking-hq.com through OpenNext. The one pipeline is .github/workflows/deploy.yml, which builds and deploys from main.

How a listing change reaches the site

Listing data is frozen into the bundle at build time (see Render model), so every edit to listings.json reaches visitors only through a rebuild. The chain, end to end:

  1. A listing changes on main — an approved issue (auto_extract, contribution_approved), the daily auto-close (closing_soon), a regenerated README (update_readmes), a gallery commit, or a maintainer's PR merge.
  2. deploy.yml starts. Human pushes trigger it directly (on: push). Bot pushes cannot — a push made with the default GITHUB_TOKEN starts no workflow run — so it also runs whenever one of those bot workflows completes (on: workflow_run), an event GitHub does deliver. A half-hourly schedule remains as a backstop, but GitHub throttles schedules to every few hours under load (23 runs in 4.5 days, 2026-08-28..09-01), so nothing relies on it any more.
  3. The job compares main with the production git tag (what was last shipped) and, if main has moved: verifies the Worker's runtime secrets, runs the credential preflight, builds, and uploads.
  4. It then confirms the public site is serving that commit — prepare-repo-data.mjs writes the build's sha to /site-data/build.json — and answers a browser request with 200 rather than a Clerk handshake. Only then does it move the production tag.
  5. site_freshness.yml runs after every deploy (and hourly): it fetches /site-data/listings.json and compares ids and fields with the repository, so a missing, stale or rolled-back listing is a red run with the names in it, not a visitor's bug report.

Expected latency from a bot commit to live: about three minutes.

Production must deploy from main. Pointing anything at a long-lived branch silently strips the site of every automated listing update, because those commits land on main and nowhere else.

Runbook: the site is behind the repository

The Site freshness run is red, or a listing you can see in listings.json is not on hacking-hq.com.

  1. Open Actions → Deploy to Cloudflare. If the latest run failed, its log says which step: a missing Worker secret, the preflight refusing a test key, the site not serving the new sha, or the visitor probe getting a Clerk handshake.
  2. If no run happened for the commit (a bot workflow was cancelled mid-push, or the workflow_run list was edited), run it by hand: Run workflow on main. Tick force to redeploy an unchanged main.
  3. If the site serves a sha that is not the tag's, something else deployed the Worker. Check Cloudflare → Workers & Pages → hackhq → Deployments for the author, and Settings → Builds for a connected repository — see Workers Builds.
  4. cd web && npm run deploy from a laptop is the last resort, and only with production values in the shell or .env.production.local; the preflight refuses a pk_test_ key for the reasons in Deploying by hand.

What the deploy workflow needs

Three details are load-bearing rather than incidental:

  • The workflow_run list is the trigger for bot commits. Every workflow that pushes to main must be listed under it; test_workflows.py fails when one is missing.
  • The production git tag is the workflow's memory of what is live. A run deploys only when main has moved past it, and only a verified deploy moves it. To force a redeploy of an unchanged main, run the workflow from the Actions tab with force checked.
  • One pipeline. Two pipelines deploying the same Worker from the same branch interleave versions with no way to tell which is live, and only this workflow runs the credential preflight. That is not hypothetical — see Workers Builds.

Until CLOUDFLARE_API_TOKEN exists as a repository secret the workflow skips with a warning rather than failing, so production only changes when someone runs npm run deploy by hand.

Deploying by hand

npm run deploy is the same command CI runs, but it builds locally — so it inlines whatever NEXT_PUBLIC_* values your shell and .env.local provide. Since .env.local normally holds a Clerk development instance, a hand deploy would ship pk_test_ to browsers while the Worker keeps verifying with the live secret key: two Clerk instances, and no working sign-in. That is not hypothetical — it took the site down on 2026-08-26, and because clerkMiddleware runs on every route it was a total outage, not just broken sign-in.

predeploy therefore runs scripts/preflight-deploy.mjs, which resolves each value exactly the way next build will — process.env first, then .env.production.local, .env.local, .env.production, .env — and refuses to build if any is missing or is a development credential. Prefer the workflow; use HACKHQ_ALLOW_NONPROD_DEPLOY=1 only for preview builds you do not intend to publish.

Environment variables in production

Set these on the hackhq Worker. The NEXT_PUBLIC_* values are inlined into the client bundle at build time (Settings → Build → Variables and secrets); the rest are server-only runtime secrets (Settings → Variables and Secrets) and must never gain a NEXT_PUBLIC_ prefix. Pass --keep-vars on deploy so dashboard runtime vars are not wiped.

Variable Scope Notes
NEXT_PUBLIC_MAPBOX_TOKEN Build, public Globe renders a placeholder without it
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY Build, public Both Clerk values or neither
CLERK_SECRET_KEY Runtime, secret Both Clerk values or neither
SUPABASE_URL Runtime, secret URL plus at least one Supabase key, and Clerk configured
SUPABASE_ANON_KEY Runtime, secret Token mode: RLS enforces ownership in Postgres. See Tracker sync modes
SUPABASE_SERVICE_ROLE_KEY Runtime, secret Service mode only; bypasses RLS (#235). Ignored when the anon key is set, removable once token mode is verified
SUPABASE_TRACKER_REQUIRE_RLS Runtime, not a secret 1 makes a deployment that cannot run in token mode refuse tracker traffic rather than degrade to app-layer enforcement. Off by default; setting it before the Clerk template and Supabase third-party auth exist takes the tracker down. Env-only rollback: unset it. Runbook
DATABASE_URL (alias SUPABASE_DATABASE_URL) Runtime, secret Highest-risk value in the repo, and the one most often left out of a table like this. A Postgres connection string embeds the database password and connects as the table owner, so RLS does not apply to it at all — it reads and writes every row of public.user_hackathons regardless of policy. Only needed wherever npm run db:* runs, which is normally a laptop rather than the Worker; leave it unset here unless something actually needs it. Rotation: docs/runbooks/rotate-credentials.md

Every one is optional and degrades gracefully: without Mapbox the globe shows a placeholder, without Clerk the tracker stays browser-local, without Supabase it stays browser-local for signed-in users too. validateEnv() in lib/env.ts warns on the half-configured cases rather than failing the build.

Auth runs as Edge middleware (why middleware.ts)

Next 16 renamed Middleware to Proxy and runs proxy.ts on the Node.js runtime. This app stays on the older middleware.ts convention on purpose, because opennextjs-cloudflare build rejects Node middleware outright:

ERROR Node.js middleware is not currently supported. Consider switching to Edge Middleware.

Edge is what middleware.ts compiles to, and clerkMiddleware runs there fine. Next prints a middleware→proxy deprecation warning; that is expected and stays until OpenNext supports Node proxy.

An earlier revision moved this to proxy.ts on the understanding that Clerk pulled Node built-ins (#crypto, #safe-node-apis) that Edge rejects with "Edge Function is referencing unsupported modules". As of @clerk/nextjs 7.6.0 that no longer happens: main carries the file as Edge middleware and Workers Builds compiles it. If you hit that error again, pin the Clerk version in the fix rather than renaming the file — the rename breaks Cloudflare.

The middleware cannot simply be deleted in favour of gating /my inside the page. auth() requires clerkMiddleware to have run; without it every server-side caller — including /api/tracker, which the synced tracker depends on — fails with "auth() was called but Clerk can't detect usage of clerkMiddleware()".

Workers Builds (must be disabled)

Cloudflare's Git integration for this Worker is connected — contrary to what this file said until 2026-09-01. It was connected on 2026-08-21 with NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY set to a development (pk_test_…) key as its build variable, and its deploy command is npx wrangler deploy --keep-vars, so it builds every push to main, bot pushes included, and promotes the result about two minutes after each commit. Every one of those builds sends first-time visitors through a Clerk dev-instance handshake and breaks sign-in until the next deploy.yml run overwrites it with a correct build. The freshness check saw that handshake chain as an unexplained HTTP 500 and was red from 2026-08-28 to 2026-09-01. To re-check the evidence: the Worker versions it produced (npx wrangler versions list, e.g. 4d71d2ee… from 2026-09-01 17:42 UTC) answer a browser request with a 307 to in-chipmunk-71.clerk.accounts.dev; the ones deploy.yml produced answer 200.

This is the one manual step left in the sync fix, because it lives in the Cloudflare dashboard rather than in this repository. Either:

  • Workers & Pages → hackhq → Settings → Builds → Disconnect — removes the integration, which is the clean end state; or
  • keep the integration but stop it deploying: set its deploy command to npx wrangler versions upload (Cloudflare's documented way to build without promoting) and change its NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY build variable to the production pk_live_… key, so a later re-enable cannot ship a dev build.

The repository half of the fix is already in place. next.config.ts calls foreignCiError() from lib/foreign-ci.ts and throws when WORKERS_CI=1 — the variable Workers Builds injects into its own builds. A Workers Builds run now fails while loading the Next config, before it has a bundle to promote, and its error names the dashboard step above. The guard sits in the config rather than an npm script because that is the one file every next build loads, whichever command invoked it. It cannot fire on a laptop or in GitHub Actions, neither of which sets WORKERS_CI. To deliberately move production onto Workers Builds later, set HACKHQ_ALLOW_FOREIGN_CI=1 as a build variable and retire deploy.yml in the same change — one pipeline, not two.

Two more guards stay, for a build that reaches production some other way (a rollback, a hand deploy): deploy.yml re-checks the served build two minutes after verifying it and fails, leaving the production tag where it was, if the build changed underneath it; and site_freshness.yml names the Clerk handshake for what it is and re-runs deploy.yml with force when it finds one, so production is put back within the hour while the run stays red.

The local tooling for Cloudflare is live, not vestigial: wrangler.jsonc, open-next.config.ts and the preview / deploy / cf-typegen scripts are what CI runs. NEXT_PUBLIC_* values are inlined at build time (CI supplies them from repository secrets); the rest are runtime secrets on the Worker (Settings → Variables & Secrets). Clerk middleware also reads the publishable key at request time, so NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY must exist in both places.

Tech stack

Notes

  • To change what appears on /, /deck, /globe, and /my, edit .github/scripts/listings.json (or the generator scripts under .github/scripts/).
  • The legacy /hackathons page reads from the root README.md instead.
  • next.config.ts allows optimized raw.githubusercontent.com images and the inline SVG banner.