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
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -269,10 +269,18 @@ jobs:
# the gate could not be evaluated rather than claiming a finding. It does
# not pass on an unevaluated check: "could not run" reported as a pass is
# the substitution this whole file exists to refuse.
#
# The gate covers what SHIPS (`--omit=dev`), since 2026-10-11: GHSA-vfj7-8cjw-p6xm
# flags every version of `braces`, reached only through eslint-config-next's
# fast-glob, with no fixed release. A finding with no fix in a tool that never
# reaches the bundle would hold every merge red with nothing anyone could do. The
# full tree is still audited below and printed as a warning, so it stays visible.
- name: Audit dependencies
run: |
npm audit --audit-level=high >/dev/null 2>&1 \
|| echo "::warning::npm audit (including dev tooling) reports findings — see 'npm audit' locally; the gate below covers shipped dependencies"
for attempt in 1 2 3; do
if out=$(npm audit --audit-level=high 2>&1); then
if out=$(npm audit --omit=dev --audit-level=high 2>&1); then
echo "$out"
exit 0
fi
Expand Down
194 changes: 40 additions & 154 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -1,223 +1,109 @@
name: Deploy to Heroku

name: Deploy site to Heroku

# 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.
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:

concurrency:
group: heroku-deploy
# TEST-17: QUEUE, never cancel. A cancellation can land between the backend release and
# the frontend release, leaving the two on different commits until the next run's
# ten-minute build finishes — or between a release and its verification, marking an
# unverified production release merely "cancelled". Waiting costs one build; the other
# costs an incoherent production nobody was told about.
cancel-in-progress: false

env:
HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }}
BACKEND_APP: ${{ vars.BACKEND_APP || 'checkmydata-api' }}
FRONTEND_APP: ${{ vars.FRONTEND_APP || 'checkmydata-web' }}
BACKEND_API_URL: ${{ vars.BACKEND_API_URL || 'https://api.checkmydata.ai/api' }}
BACKEND_WS_URL: ${{ vars.BACKEND_WS_URL || 'wss://api.checkmydata.ai/api/chat/ws' }}
FRONTEND_URL: ${{ vars.FRONTEND_URL || 'https://checkmydata.ai' }}

jobs:
deploy:
runs-on: ubuntu-latest
# `event == 'push'` is the half that is not obvious. `branches: [main]` above
# matches the HEAD branch of the CI run, and since CI now runs on every pull
# request, a PR opened *from* `main` would produce a run that satisfies it.
# Deploy follows a merge, never a proposal.
if: >-
${{ github.event.workflow_run.conclusion == 'success'
&& github.event.workflow_run.event == 'push' }}
# Only what is on `main` ships, whichever branch the button was pressed on.
if: ${{ github.ref == 'refs/heads/main' }}

steps:
- uses: actions/checkout@v5
with:
ref: ${{ github.event.workflow_run.head_sha }}

- name: Login to Heroku Container Registry
run: echo "$HEROKU_API_KEY" | docker login -u _ --password-stdin registry.heroku.com

- name: Build backend image
# GIT_SHA is what Sentry's `release` becomes. It has to be the commit the image
# was built from, and it has to match the release the commits are attached to —
# `workflow_run.head_sha` is the same value this job checked out above.
run: |
docker build \
--platform linux/amd64 \
--build-arg GIT_SHA=${{ github.event.workflow_run.head_sha }} \
-t registry.heroku.com/${{ env.BACKEND_APP }}/web \
-f Dockerfile.backend .

- name: Build worker image
run: |
docker build \
--platform linux/amd64 \
--build-arg BASE_IMAGE=registry.heroku.com/${{ env.BACKEND_APP }}/web \
--build-arg GIT_SHA=${{ github.event.workflow_run.head_sha }} \
-t registry.heroku.com/${{ env.BACKEND_APP }}/worker \
-f Dockerfile.worker .

- name: Build release-phase image
# Same image, one different CMD — the shape Dockerfile.worker already uses. This is
# what runs `alembic upgrade head` BEFORE the new release goes live (TEST-15).
run: |
docker build \
--platform linux/amd64 \
--build-arg BASE_IMAGE=registry.heroku.com/${{ env.BACKEND_APP }}/web \
-t registry.heroku.com/${{ env.BACKEND_APP }}/release \
-f Dockerfile.release .

- name: Build frontend image
- name: Build site image
run: |
docker build \
--platform linux/amd64 \
--build-arg NEXT_PUBLIC_API_URL=${{ env.BACKEND_API_URL }} \
--build-arg NEXT_PUBLIC_WS_URL=${{ env.BACKEND_WS_URL }} \
--build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_CLIENT_ID }} \
--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: Push backend image
run: docker push registry.heroku.com/${{ env.BACKEND_APP }}/web

- name: Push worker image
run: docker push registry.heroku.com/${{ env.BACKEND_APP }}/worker

- name: Push release-phase image
run: docker push registry.heroku.com/${{ env.BACKEND_APP }}/release

- name: Push frontend image
- name: Push site image
run: docker push registry.heroku.com/${{ env.FRONTEND_APP }}/web

- name: Note the release version before deploying
# The whole verification rests on this: "a release exists and is healthy" is not the
# same statement as "MY release happened", and only a before/after comparison can
# tell them apart (TEST-01).
# `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
# `Range:` is load-bearing, not tidiness. The releases collection is paginated
# and returned in ASCENDING order, so a plain GET yields the OLDEST 200 releases
# and `[.[].version] | max` reads 200 on an app that is at 375 — the version then
# never appears to move and the verification below waits out its attempts on a
# release that already succeeded. Measured on production 2026-09-11, by this step
# failing its own first run.
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.BACKEND_APP }}/releases | jq '.[0].version')
https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/releases | jq '.[0].version')
echo "BEFORE_VERSION=$BEFORE_VERSION" >> "$GITHUB_ENV"
echo "Backend is at release v$BEFORE_VERSION before this deploy"
echo "Site is at release v$BEFORE_VERSION before this deploy"

- name: Release backend
- name: Release site
run: |
set -euo pipefail
WEB_IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.BACKEND_APP }}/web)
WORKER_IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.BACKEND_APP }}/worker)
RELEASE_IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.BACKEND_APP }}/release)
# `--fail-with-body`: without it curl exits 0 on 401/403/404/422, so a rotated
# API key or a refused image id was a PASSING step and production silently
# stopped receiving deploys (TEST-01). `-w`/`-o` are not enough on their own —
# the exit code is what the runner reads.
curl --fail-with-body -sS -X PATCH https://api.heroku.com/apps/${{ env.BACKEND_APP }}/formation \
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":"'"$WEB_IMAGE_ID"'"},{"type":"worker","docker_image":"'"$WORKER_IMAGE_ID"'"},{"type":"release","docker_image":"'"$RELEASE_IMAGE_ID"'"}]}'
-d '{"updates":[{"type":"web","docker_image":"'"$IMAGE_ID"'"}]}'

- name: Verify the backend release actually happened
# Reads the platform, not a public URL. The health endpoint answers from whatever is
# live, so when the release fails it is the OLD release that returns 200 — which is
# how a broken deploy reported itself healthy. A `failed` status here also covers
# the release phase: a migration that does not apply aborts the release.
- 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.BACKEND_APP }}/releases)
https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/releases)
VERSION=$(jq '.[0].version' <<<"$LATEST")
STATUS=$(jq -r '.[0].status' <<<"$LATEST")
if [ "$VERSION" -le "$BEFORE_VERSION" ]; then
echo "Attempt $i: still at v$VERSION, waiting for a new release..."
elif [ "$STATUS" = "succeeded" ]; then
echo "Backend released: v$BEFORE_VERSION -> v$VERSION (status $STATUS)"
echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV"
if [ "$VERSION" -gt "$BEFORE_VERSION" ] && [ "$STATUS" = "succeeded" ]; then
echo "Site released: v$BEFORE_VERSION -> v$VERSION"
exit 0
elif [ "$STATUS" = "failed" ]; then
echo "Release v$VERSION FAILED — the release phase (alembic upgrade head) did not succeed"
elif [ "$VERSION" -gt "$BEFORE_VERSION" ] && [ "$STATUS" = "failed" ]; then
echo "Release v$VERSION FAILED"
exit 1
else
echo "Attempt $i: v$VERSION is $STATUS..."
fi
sleep 15
done
echo "No successful release appeared after this deploy (still at v$BEFORE_VERSION)"
exit 1

- name: Verify the worker is running the new release
# TEST-16: both health checks speak HTTP and the worker serves none, so a worker
# image that dies on import deployed green — on the process type this repo's own
# incident history names as the recurring failure surface.
run: |
set -euo pipefail
for i in $(seq 1 20); do
DYNOS=$(curl --fail-with-body -sS \
-H "Accept: application/vnd.heroku+json; version=3" \
-H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \
https://api.heroku.com/apps/${{ env.BACKEND_APP }}/dynos)
UP=$(jq -r --argjson v "$RELEASE_VERSION" \
'[.[] | select(.type == "worker" and .state == "up" and .release.version == $v)] | length' \
<<<"$DYNOS")
if [ "$UP" -ge 1 ]; then
echo "Worker is up on release v$RELEASE_VERSION"
exit 0
fi
echo "Attempt $i: no worker dyno up on v$RELEASE_VERSION yet..."
echo "Attempt $i: v$VERSION is $STATUS, waiting..."
sleep 15
done
echo "The worker never came up on release v$RELEASE_VERSION"
echo "No successful release appeared (still at v$BEFORE_VERSION)"
exit 1

- name: Release frontend
- 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.
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 backend health
run: |
for i in 1 2 3 4 5; do
sleep 30
STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ env.BACKEND_API_URL }}/health)
if [ "$STATUS" = "200" ]; then
echo "Backend is healthy (HTTP $STATUS) on attempt $i"
sleep 20
HOME_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ env.FRONTEND_URL }}")
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)"
exit 0
fi
echo "Attempt $i: backend returned $STATUS, retrying..."
echo "Attempt $i: home $HOME_STATUS, waitlist $WAITLIST_STATUS"
done
echo "Backend health check failed after 5 attempts (last status: $STATUS)"
exit 1

- name: Verify frontend health
run: |
sleep 15
STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ env.FRONTEND_URL }})
if [ "$STATUS" != "200" ]; then
echo "Frontend health check failed with status $STATUS"
exit 1
fi
echo "Frontend is healthy (HTTP $STATUS)"
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### 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
maintenance mode; its Heroku Postgres and Redis add-ons and the Supabase project were
deleted after `pg_dump` backups (kept privately, outside Git). The deploy workflow no
longer fires on a green CI run: it builds and releases the site only, by hand.
- The site's "Get Started" / "Log in" / "Try it now" CTAs now lead to a waitlist form
(`#waitlist`). A sign-up is one email to `contact@checkmydata.ai` sent by the site's own
Next.js server through Resend (`POST /api/waitlist`) — no database. A sign-up that was not
mailed is reported to the visitor as a failure, never acknowledged.
- `/app`, `/login`, `/pricing`, `/dashboard/…` and the password/verification pages
redirect to `/#waitlist`. Their code stays for a relaunch; self-hosting is unchanged.

### Fixed — a full rebuild interrupted part-way resumes, against its own tree (PRJ-06 T12a, #441)

- A restart during a full rebuild used to pay for all of it again: the run created a fresh
Expand Down
13 changes: 11 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ 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
> 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.

- **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`).
- **Supported analytics sources**: `Connection.source_type` also accepts three analytics vendors (`backend/app/analytics/`, family defined once in `app/analytics/source_types.py`): **`ga4`** (Google Analytics 4 — the only one with a collector today), plus **`appstore`** and **`googleplay`**, reserved for m1/m2 (connection creation refuses them with 422 until their fact tables land). Runbook: `docs/ANALYTICS_SOURCES.md`.
Expand Down Expand Up @@ -1153,8 +1162,8 @@ Read `vision.md` before any new feature. If a request conflicts with §7 invaria

- `docs/ux/scenarios.md` is the source of truth for all user-facing behavior.
- **The chain now has three layers, and they are not equally complete.**
`scenarios.md` covers the whole product (**154 scenarios; 142 `implemented`, 12
`draft`** — measured 2026-09-18 by `make ux-status`, and asserted by
`scenarios.md` covers the whole product (**155 scenarios; 141 `implemented`, 12
`draft`, 2 `retired`** — measured 2026-10-11 by `make ux-status`, and asserted by
`test_the_ux_base_checks_all_three_documents.py`, because the previous figure
here said 151/128/23 while the generated block in the document itself counted
153/141/12 and nothing compared the two). `flows.md` (FLW-01–05) and `screens.md` (SCR-01–10) were added
Expand Down
Loading
Loading