Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,9 @@ jobs:
run: npm run build
env:
NEXT_PUBLIC_API_URL: "http://localhost:8000/api"

# checkmydata.ai is the OTHER build of this tree: a static export on Cloudflare
# (SITE_EXPORT=1, 2026-10-12). It is deployed by hand, so a page that cannot be
# exported would otherwise surface only when someone presses Deploy.
- name: Build the static site (checkmydata.ai)
run: npm run build:site
132 changes: 53 additions & 79 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,109 +1,83 @@
name: Deploy site to Heroku
name: Deploy site to Cloudflare

# Site only, and only by hand (operator, 2026-10-11). The hosted product was switched
# off: `checkmydata-api` runs no dynos and holds no database, so this workflow builds
# and releases the marketing site and nothing else. It used to fire on every green CI
# run on `main`; a merge no longer deploys — press "Run workflow" when the site should
# change. The backend half (images, release phase, worker check) is in git history at
# the commit before this one, for a relaunch.
# Site only, and only by hand (operator, 2026-10-11). The hosted product is off, and
# since 2026-10-12 checkmydata.ai is a static export on Cloudflare Workers — no server,
# no monthly cost. Press "Run workflow" when the site should change; a merge does not
# deploy. The Heroku pipeline this replaced is in git history, for a relaunch.
#
# Credentials, each narrow (issued per project, never an account admin key):
# secrets.CLOUDFLARE_WORKERS_TOKEN — Workers Scripts Write on the account
# secrets.CLOUDFLARE_ROUTES_TOKEN — Workers Routes Write on the checkmydata.ai zone
# vars.CLOUDFLARE_ACCOUNT_ID
# RESEND_API_KEY is a Worker secret set once with `wrangler secret put`, not here.
on:
workflow_dispatch:

concurrency:
group: heroku-deploy
group: site-deploy
cancel-in-progress: false

env:
HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }}
FRONTEND_APP: ${{ vars.FRONTEND_APP || 'checkmydata-web' }}
FRONTEND_URL: ${{ vars.FRONTEND_URL || 'https://checkmydata.ai' }}
SITE_URL: https://checkmydata.ai

jobs:
deploy:
runs-on: ubuntu-latest
# Only what is on `main` ships, whichever branch the button was pressed on.
if: ${{ github.ref == 'refs/heads/main' }}
defaults:
run:
working-directory: frontend

steps:
- uses: actions/checkout@v5

- name: Login to Heroku Container Registry
run: echo "$HEROKU_API_KEY" | docker login -u _ --password-stdin registry.heroku.com
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Build site image
run: |
docker build \
--platform linux/amd64 \
--build-arg NEXT_PUBLIC_SENTRY_DSN=${{ vars.NEXT_PUBLIC_SENTRY_DSN }} \
--build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=production \
-t registry.heroku.com/${{ env.FRONTEND_APP }}/web \
-f Dockerfile.frontend .
- name: Install dependencies
run: npm ci

- name: Push site image
run: docker push registry.heroku.com/${{ env.FRONTEND_APP }}/web
- name: Build the static site
run: npm run build:site
env:
NEXT_PUBLIC_SENTRY_DSN: ${{ vars.NEXT_PUBLIC_SENTRY_DSN }}
NEXT_PUBLIC_SENTRY_ENVIRONMENT: production

- name: Note the release version before deploying
# `Range:` because the releases collection is paginated in ascending order; without
# it the newest version is never read and the check below waits on nothing.
run: |
set -euo pipefail
BEFORE_VERSION=$(curl --fail-with-body -sS \
-H "Accept: application/vnd.heroku+json; version=3" \
-H "Range: version ..; order=desc, max=1" \
-H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \
https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/releases | jq '.[0].version')
echo "BEFORE_VERSION=$BEFORE_VERSION" >> "$GITHUB_ENV"
echo "Site is at release v$BEFORE_VERSION before this deploy"
- name: Deploy the Worker and its assets
# wrangler exits non-zero on any refused upload, so a failed deploy fails here.
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_WORKERS_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }}

- name: Release site
run: |
set -euo pipefail
IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.FRONTEND_APP }}/web)
curl --fail-with-body -sS -X PATCH https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/formation \
-H "Content-Type: application/json" \
-H "Accept: application/vnd.heroku+json; version=3.docker-releases" \
-H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \
-d '{"updates":[{"type":"web","docker_image":"'"$IMAGE_ID"'"}]}'

- name: Verify the release actually happened
# The platform, not the public URL: when a release fails, the OLD one still answers 200.
run: |
set -euo pipefail
for i in $(seq 1 20); do
LATEST=$(curl --fail-with-body -sS \
-H "Accept: application/vnd.heroku+json; version=3" \
-H "Range: version ..; order=desc, max=1" \
-H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \
https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/releases)
VERSION=$(jq '.[0].version' <<<"$LATEST")
STATUS=$(jq -r '.[0].status' <<<"$LATEST")
if [ "$VERSION" -gt "$BEFORE_VERSION" ] && [ "$STATUS" = "succeeded" ]; then
echo "Site released: v$BEFORE_VERSION -> v$VERSION"
exit 0
elif [ "$VERSION" -gt "$BEFORE_VERSION" ] && [ "$STATUS" = "failed" ]; then
echo "Release v$VERSION FAILED"
exit 1
fi
echo "Attempt $i: v$VERSION is $STATUS, waiting..."
sleep 15
done
echo "No successful release appeared (still at v$BEFORE_VERSION)"
exit 1
- name: Put the Worker in front of checkmydata.ai
run: node scripts/attach-site-route.mjs
env:
CLOUDFLARE_ROUTES_TOKEN: ${{ secrets.CLOUDFLARE_ROUTES_TOKEN }}

- name: Verify the site and the waitlist endpoint
# An empty sign-up must be refused with 422: that proves the route is deployed and
# validating, without mailing the team inbox on every deploy.
- name: Verify the live site is the Worker
# Three readings, each one only the Worker gives: its JSON 404 for an unknown
# /api path (an older origin answers with an HTML page), the waitlist refusing an
# EMPTY body with 422 (proves the endpoint without mailing the inbox), and a
# retired product route redirecting to the waitlist.
run: |
set -euo pipefail
for i in 1 2 3 4 5; do
sleep 20
HOME_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ env.FRONTEND_URL }}")
for i in 1 2 3 4 5 6; do
HOME_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$SITE_URL/")
PROBE=$(curl -s "$SITE_URL/api/deploy-probe" || true)
WAITLIST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
-H "Content-Type: application/json" -d '{}' "${{ env.FRONTEND_URL }}/api/waitlist")
if [ "$HOME_STATUS" = "200" ] && [ "$WAITLIST_STATUS" = "422" ]; then
echo "Site 200, waitlist validates (422 on an empty body)"
-H "Content-Type: application/json" -d '{}' "$SITE_URL/api/waitlist")
LOGIN=$(curl -s -o /dev/null -w "%{http_code} %{redirect_url}" "$SITE_URL/login")
if [ "$HOME_STATUS" = "200" ] && [ "$PROBE" = '{"ok":false,"error":"Not found."}' ] \
&& [ "$WAITLIST_STATUS" = "422" ] && [ "$LOGIN" = "307 $SITE_URL/#waitlist" ]; then
echo "Live: home 200, Worker probe, waitlist 422, /login → /#waitlist"
exit 0
fi
echo "Attempt $i: home $HOME_STATUS, waitlist $WAITLIST_STATUS"
echo "Attempt $i: home $HOME_STATUS, probe '$PROBE', waitlist $WAITLIST_STATUS, login '$LOGIN'"
sleep 15
done
exit 1
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ backend/data/
node_modules
frontend/.next/
frontend/out/
frontend/.wrangler/

# IDE
.vscode/
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed — checkmydata.ai is a static site on Cloudflare; hosting costs nothing (2026-10-12)

- The last running cost was the site's own Heroku dyno. `npm run build:site`
(`SITE_EXPORT=1`) now builds a static export served by the Cloudflare Worker
`checkmydata-site` (`frontend/wrangler.jsonc`); the Worker runs only for `/api/*` and
serves the waitlist, with a Cloudflare rate-limit binding (5 per minute per address).
Security headers and the retired-route redirects moved to `public/_headers` and
`public/_redirects`. Both Heroku apps now run 0 dynos. `deploy.yml` deploys to
Cloudflare by hand and verifies the live domain answers the way only the Worker can.
- **Fixed: the waitlist redirects no longer apply to a self-hosted build.** In the
2026-10-11 change they lived in `next.config.ts` for every build, so a self-hoster's
`/login` redirected to a waitlist. A plain `npm run build` is the standalone product
again, with its headers and no redirects. `/dashboard/<id>` keeps its URL there through
a rewrite to the one page the export prerenders.

### Changed — the hosted product is switched off; checkmydata.ai is a waitlist (2026-10-11)

- Operator decision, to stop the running cost. `checkmydata-api` runs no dynos and is in
Expand Down
15 changes: 9 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,17 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Product snapshot

> **Hosting status (2026-10-11): the hosted product is OFF.** Operator decision to stop
> the running cost. Production is the marketing site alone (`checkmydata-web`, Heroku
> Basic), with a waitlist that mails `contact@checkmydata.ai` (`POST /api/waitlist`,
> Resend; SCN-153). `checkmydata-api` has 0 dynos, maintenance on, no add-ons; the
> **Hosting status (2026-10-11/12): the hosted product is OFF, and hosting costs $0.**
> Operator decision to stop the running cost. Production is the marketing site alone, a
> static export (`npm run build:site`, `SITE_EXPORT=1`) on Cloudflare Workers — Worker
> `checkmydata-site`, route `checkmydata.ai/*`; `frontend/worker/index.ts` serves the
> waitlist (`POST /api/waitlist` → Resend → `contact@checkmydata.ai`; SCN-153) and
> `public/_headers` / `public/_redirects` do the rest. Both Heroku apps run 0 dynos; the
> Supabase project and the Heroku Postgres/Redis were deleted after `pg_dump` backups
> (private, outside Git — see `docs/handoffs/2026-10-11-hosted-product-off.md`). The
> deploy workflow is manual and site-only. Everything below describes the product as it
> stood when it ran, and as it still runs self-hosted. Relaunch steps are in the handoff.
> deploy workflow is manual and site-only. A plain `npm run build` is still the
> standalone self-hosted product, with no redirects. Everything below describes the
> product as it stood when it ran, and as it still runs self-hosted.

- **What it is**: AI-powered database query agent — natural language → SQL, codebase Q&A, visualizations, team workspaces.
- **Supported databases**: PostgreSQL, MySQL, ClickHouse, MongoDB (via `backend/app/connectors/`), plus **SQLite** — file-backed, added 2026-08-20 because the demo path has created `db_type="sqlite"` connections since it existed while `ADAPTER_REGISTRY` had no entry for it, so every demo connection raised `Unsupported adapter: sqlite` on the first question. Not offered in the connection UI; it is what the demo runs on (`app/services/demo_data.py`).
Expand Down
Loading
Loading