The repo deploys to Cloudflare Workers: one Worker (worker/index.ts)
serves the static SPA and routes the /api/nodes + /api/kv sync APIs to a
per-user Durable Object, behind Better Auth accounts
(the auth gate). Config is in wrangler.jsonc.
Full design: the sync design.
bun install
bun run setup # generates BETTER_AUTH_SECRET + applies the local D1 schema
bun run dev # starts the app (vite :3000 + worker :8787) in one command
bun run seed:user # (optional) creates dev@dotflowy.local / dotflowy-dev to sign in with
# or a production-like single-server preview
bun run cf:dev # build + wrangler dev
# ship it
wrangler secret put BETTER_AUTH_SECRET # once: the auth signing secret
wrangler secret put INVITE_CODES # optional: invite codes (a signup backdoor; see Auth below)
bun run db:migrate:remote # before the first deploy
bun run deploy # build + wrangler deployThe local invite code is dev-invite if you'd rather sign up your own
account; bun run seed:user skips that by creating a ready-to-use account.
build:cf copies the TanStack Start shell (_shell.html) to index.html so
the root and client routes (e.g. /<nodeId> zoom views) resolve through the
SPA fallback.
The App Release and Deploy workflow runs on every push to main. Merging
approves the app release and production deployment. It selects current main,
runs the same quality checks as CI, then:
- Runs
bun scripts/release.ts --ci. Nonempty app changesets produce a version, archived changelog, release commit, and annotated tag. No news means no bump. - Typechecks the landing site and builds both sites before publishing anything.
- Pushes only the prepared commit to
release-candidate/<run-id>-<attempt>and dispatchesCLIagainst that branch. Waits up to 45 minutes for all seven real jobs to succeed on that SHA in the identified run attempt. No-news deploys validate the selected main commit the same way. - Confirms main has not advanced, pushes the identical release commit and tag atomically, then publishes the GitHub Release.
- Deploys the landing Worker (
dotflowy.com), then the app Worker (app.dotflowy.com), using those builds. - Checks the version in both public
changelog.jsonassets, both homepages, and the app's public auth configuration endpoint.
Only one production workflow runs at a time. It finishes the active deployment;
intermediate waiting runs can be replaced by the newest run. The next run selects
current main and releases all pending app changesets together. The CLI's npm
release workflow remains independent.
Add these under Settings → Secrets and variables → Actions → New repository secret:
| Name | Value |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare deployment token, using the Edit Cloudflare Workers template, scoped to the account and dotflowy.com zone. |
CLOUDFLARE_ACCOUNT_ID |
The account that owns both Workers. |
SENTRY_AUTH_TOKEN |
Optional Sentry organization token for client source-map uploads. |
The workflow uses GitHub's built-in GITHUB_TOKEN with Contents write permission
for candidate branches, release commits, tags, and GitHub Releases. The deploy
job also has Actions write permission to dispatch, observe, and cancel candidate
validation. Pushes do not recursively trigger workflows; the validation dispatch
is explicit. Validation has read-only repository permissions, no persisted Git
credentials, and no release secrets, npm environment, or OIDC permission.
The seven required CLI checks must be earned on the generated SHA, not its
parent. Configure main protection only after the hosted protocol is verified,
following ADR 0073.
PR-required protection needs a different release approval flow. Never add a
bypass or force push. Runtime secrets such as BETTER_AUTH_SECRET stay in
Cloudflare; deployment authentication is unchanged.
The token template grants account-level Worker access, not access limited to these two Workers. Keep account and zone resource scopes as narrow as possible.
The app build sets SENTRY_RELEASE to the prepared candidate's Git SHA, after
release preparation. Sentry uses that value for both the uploaded artifacts and
the browser SDK. Do not use GITHUB_SHA: it identifies the triggering commit,
not necessarily the selected base or the generated release commit.
Source-map generation and a successful upload do not prove symbolication. With authorized Sentry read access, verify a fresh, content-free synthetic exception from the deployed app in a signed-out browser:
- Match the deployed version, release commit, browser event's
release, and upload log. Match itsdebug_metaID to the uploaded artifact bundle. - Inspect the processed event in Sentry. Confirm an original
src/filename, function, line, and source context, not just a minified asset frame. Confirm the matching artifact is available under the project's Source Maps. - Verify that public
.mapURLs do not return source maps. The SPA can return HTML with status 200 for a missing asset, so inspect the content type and body.
Use no account, outline content, cookies, query strings, or user breadcrumbs in
the sentinel. Keep SENTRY_AUTH_TOKEN build-time only. Local tokenless builds
still skip source maps; uploaded client maps are deleted before deployment.
Cloudflare's Worker source-map upload is separate and does not prove browser
symbolication in Sentry.
Sentry documents 90-day idle retention for Debug ID artifacts, renewed by event processing, not indefinite retention. An upload log is evidence of upload at that time, not current availability. Without Sentry read access, report processed frames, source context, and artifact availability as unverified; an ingestion HTTP 200 alone is not enough to close the verification.
A failed step stops the run. If landing deploys but the app fails, the sites can temporarily differ. The GitHub Release can also precede the live app. Neither failure triggers an automatic rollback.
To retry, open Actions → App Release and Deploy → Run workflow, select main,
and run it. This validates current main, not an old failed commit. If no newer
news has merged, it reuses the pushed version/tag and skips an existing published
GitHub Release. It rebuilds and redeploys both sites. Prefer a fresh run over
rerunning only the failed job, which retains the earlier selected commit.
A merge during release preparation rejects the atomic push, before the GitHub
Release or either deployment. The queued run handles the newer main. Never
force or rebase the already-built candidate.
The workflow also checks for a superseded base before release preparation, before candidate dispatch, and every 15 seconds while awaiting candidate checks. These early exits avoid unnecessary builds and validation; they do not replace the final identity checks and atomic push. A merge during app quality checks still waits for those checks to finish. Active production runs are not canceled by newer pushes, so a deployment already publishing cannot be interrupted by PR-style concurrency cancellation.
Failed, missing, skipped, cancelled, or timed-out candidate checks stop before promotion. The workflow summary records the candidate SHA, captured base, and validation run ID. Inspect that run rather than looking for any green check on the branch. A lost dispatch response stops without redispatching automatically.
Cleanup attempts to cancel known unfinished validation and delete only that attempt's temporary branch. Cleanup failures do not change the release outcome. If the run ID is unknown or cancellation cannot settle, the branch is retained; workflow termination can also leave an orphan. After confirming its validation run has stopped, an authorized maintainer can delete that exact branch. Do not delete another attempt's candidate, change its SHA, or move a release tag.
The workflow does not run D1 SQL migrations or data backfills. Approve and run
those separately before deploying code that requires them. Wrangler still applies
Durable Object class migrations declared in wrangler.jsonc; review changes to
that migration list before merging.
Identity is Better Auth (email + password, with email verification),
sessions in D1. Signup is open — SIGNUP_OPEN="true", human-gated by
Cloudflare Turnstile (TURNSTILE_SECRET_KEY + the public
TURNSTILE_SITE_KEY). Leave SIGNUP_OPEN unset to fall back to invite-only,
where an account needs a code from the INVITE_CODES secret or a per-email
invite; both stay valid as backdoors in every state, and the public
POST /api/waitlist still collects emails (viewable by admins — the
ADMIN_EMAILS var — at /admin/waitlist).
The static shell is public so the login screen loads; only /api/nodes +
/api/kv require a session. Set BETTER_AUTH_SECRET
(wrangler secret put) in prod and .dev.vars locally — without it the
Worker fails closed. To carry a pre-auth outline (the constant 'default' DO)
into your real account, set the OWNER_USER_ID secret to your user.id after
signing up. See the auth gate.