Everything needed to go from this repo to production. Order matters — GitHub App first (it produces most of the env values).
Create at https://github.com/settings/apps/new (name suggestion: simbox).
| Field | Value |
|---|---|
| Homepage URL | https://simbox.touchtech.club |
| Callback URL | https://api.simbox.touchtech.club/api/auth/callback/github |
| Setup URL | https://simbox.touchtech.club/onboarding (redirect on install ✓) |
| Webhook URL | https://api.simbox.touchtech.club/webhooks/github |
| Webhook secret | generate: openssl rand -hex 32 → GITHUB_WEBHOOK_SECRET |
| Request user authorization (OAuth) during installation | ✓ enabled |
| Description (markdown, shown on install page) | Tells users: install on a NEW, EMPTY repository only — see scripts/simbox-app-manifest.html |
Permissions (Repository):
Contents→ Read & writeWorkflows→ Read & write (required to commit .github/workflows/*)Actions→ Read & writeSecrets→ Read & writeMetadata→ Read (automatic)
Subscribe to events: workflow_run (+ installation / installation_repositories — these are account-level events every app receives implicitly; they're not selectable in the manifest but the webhook deliveries still arrive at our endpoint)
Collect after creation:
- App ID →
GITHUB_APP_ID - OAuth Client ID + Client secret (App page → "Client secrets") →
GITHUB_CLIENT_ID/GITHUB_CLIENT_SECRET(these double as the better-auth GitHub provider) - Generate a Private key (PEM download) →
GITHUB_APP_PRIVATE_KEY
Local domains work for dev too — create a second dev App with callback
http://localhost:8787/api/auth/callback/githubif you want full auth locally.
One-time:
cd packages/infra
bunx alchemy profile edit # or export CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID
cp .env.example .env # fill values below# --- from GitHub App ---
GITHUB_APP_ID=1234567
GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----"
GITHUB_WEBHOOK_SECRET= # openssl rand -hex 32
GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxxxxxx
GITHUB_CLIENT_SECRET= # openssl rand -hex 32 works for BETTER_AUTH_SECRET too
# --- generated ---
BETTER_AUTH_SECRET= # openssl rand -base64 32
# --- after first deploy (see §3) ---
APP_URL= # leave empty for first deploy → set to web URL → redeploy
# --- never in prod ---
DEMO_MODE=falseAlchemy provisions automatically: D1 simbox-db (migrations from packages/db/migrations apply on deploy), KV simbox-kv, Worker simbox-api, Website simbox-web.
bun run deploy # in packages/infra (or root)
# → prints api + web URLsAPP_URL must equal the web origin for CORS + OAuth, but it's only known after deploy. So:
bun run deploy→ note the printedwebURL- Set
APP_URL=https://<web-url>in.env(or add custom domain first — below) bun run deployagain
Default deploy lands on *.workers.dev / *.pages.dev-style URLs. For the real product:
| Domain | Points to |
|---|---|
simbox.touchtech.club |
simbox-web website |
api.simbox.touchtech.club |
simbox-api worker |
Add as custom domains in the CF dashboard (or Cloudflare.Domain resources in alchemy.run.ts), then set APP_URL=https://simbox.touchtech.club and update PROD_API_URL in packages/shared/src/constants.ts + API_URL var → https://api.simbox.touchtech.club, redeploy.
GitHub App callback + webhook URLs must match the final API domain — update them in App settings if you add api.simbox.touchtech.club.
Runners get a named Cloudflare tunnel per run at r-<16 hex>.<TUNNEL_ZONE> (default simboxtouchtech.dpdns.org). A named tunnel keeps four edge connections and one URL for the whole run; a quick tunnel keeps one connection, so every edge drop is an outage and a restart changes the URL. Hostnames must sit one label under a Cloudflare zone so free Universal SSL covers them.
Create a token (CF dashboard → My Profile → API Tokens → Create Custom Token):
- Account › Cloudflare Tunnel: Edit
- Zone › Zone: Read and DNS: Edit, limited to the
TUNNEL_ZONEzone
Save it as the CLOUDFLARE_TUNNEL_API_TOKEN secret and redeploy. Without that secret the deploy passes CLOUDFLARE_API_TOKEN instead (it currently carries these permissions too), so the worker then holds the deploy token; a dedicated token keeps the worker to tunnel and DNS access only. If neither token can provision, /v1/runs/tunnel fails and runners stop before going live. The API deletes a run's tunnel and DNS record when the agent deregisters; a 10-minute cron also releases ended or expired runs and deletes simbox-run-* tunnels and simbox run DNS records with no live run.
To move zones, set the TUNNEL_ZONE variable and redeploy. S5 Code accepts r-<16 hex> under simboxtouchtech.dpdns.org and simbox.dpdns.org (see S5's SimboxDeviceHost.ts).
cd apps/cli
npm publish --access public # first publish of @touchtechclub/simbox must be manual (trusted publishing needs the package to exist)CLI defaults to PROD_API_URL; users can override via SIMBOX_API_URL / SIMBOX_WEB_URL env.
Use simbox exec <agent-device command> for device operations. It injects the
run's Simlock agent token, resolves the latest tunnel on each command, and uses
a stable session per run and agent in Simbox's own state directory (without
modifying agent-device global/proxy profiles). Cold startup gets a 300000 ms
budget. Clients should use agent-device@0.21.35, matching the runner pin.
Runners whose agent predates Simlock are refused at registration (409
agent_outdated), and new agents need the API's Simlock tunnel port, so
release the action and deploy the API together, then run simbox repair in
each repository.
Android testing uses simbox sim --platform android --new: the repaired workflow
dispatches ubuntu-latest, grants /dev/kvm access, and downloads the sha256-pinned
Linux x64 agent. iOS defaults to GitHub's xcode-27 ARM64 image. Each user can
have up to 5 iOS and 10 Android runs live at once (MAX_ACTIVE_RUNS); each caller
keeps its own run. The concurrent_runs migration must be applied before the API
that uses it deploys, and before releasing the CLI/agent. Run simbox repair
after upgrading to install the current workflow (platform, runner_labels and
simbox_run inputs, staged steps); older workflows run one platform at a time.
A run is ready once Simlock reports a warm device and the agent's self-test passed.
The committed workflow uses TouchTechClub/runner@v1 — needs a real repo:
# 1. Create github.com/TouchTechClub/simbox, push this monorepo there
git remote add origin git@github.com:TouchTechClub/simbox.git && git push -u origin main
# 2. Create github.com/TouchTechClub/runner, push action-src/ contents there
# (action.yml at repo root), tag it:
cd action-src && git init && git add -A && git commit -m "v1" \
&& git remote add origin git@github.com:TouchTechClub/runner.git && git push -u origin main \
&& git tag v1 && git push origin v1
# 3. Build + publish the agent release (cross-compiles darwin-arm64 + linux-x64)
cd action-src && ./release.sh 1.0.0
# → builds packages/agent, tars, computes sha256, re-renders action.yml,
# prints gh release commands for TouchTechClub/simbox/releases/tag/v1.0.0action.yml pins the release it was rendered for: its version input defaults
to that tag, whose tarballs match the embedded sha256. Workflows keep using
TouchTechClub/runner@v1; the v1 tag of the action repo moves, the agent
release it downloads does not.
apps/api/.dev.vars (gitignored):
APP_URL=http://localhost:5173
API_URL=http://localhost:8787
GITHUB_APP_ID= # dev app
GITHUB_APP_PRIVATE_KEY=
GITHUB_WEBHOOK_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
BETTER_AUTH_SECRET= # any 32+ char string
DEMO_MODE=true # bypasses auth, seeds fake repo+live run — preview onlybun run dev:api # wrangler dev :8787 (auto-applies drizzle migrations)
bun run dev:web # vite :5173, proxies /api/auth + /v1 → :8787.github/workflows/release.yml — git tag v0.2.0 && git push origin v0.2.0 runs:
quality (typecheck+lint) → agent build + GH release v0.2.0 (macos) → TouchTechClub/runner action.yml update + v1 retag, and npm publish → alchemy deploy (last). The rolling v1 release's tarballs are no longer overwritten.
Publish the action/agent release that understands the step input before
deploying an API that commits v2 (staged) workflows; otherwise newly committed
or repaired workflows call an action that ignores step and fail. release.yml enforces this
(deploy needs runner and cli); keep the same order for manual releases.
The platform_runs_devices_workflow D1 migration applies before the new Worker.
Before creating the one-active-run-per-platform unique index it ends all but the
newest active run per user + platform (state = 'ended',
end_reason = 'superseded').
Secrets (production environment for deploy job, npm environment for cli job, or repo-level — GH falls back):
| Secret | Value |
|---|---|
CLOUDFLARE_API_TOKEN |
CF API token — Workers + D1 + KV + (zone DNS + Workers Routes for custom domains) |
ALCHEMY_STATE_STORE_CREDENTIALS |
contents of ~/.alchemy/credentials/default/cloudflare-state-store.json after your first local alchemy deploy |
GH_APP_ID |
5036209 — GITHUB_ prefix is reserved, hence GH_ |
GH_APP_PRIVATE_KEY |
full PEM (literal newlines OK in secrets) |
GH_WEBHOOK_SECRET |
app webhook secret |
GH_CLIENT_ID / GH_CLIENT_SECRET |
app OAuth creds |
BETTER_AUTH_SECRET |
openssl rand -base64 32 |
CLOUDFLARE_TUNNEL_API_TOKEN |
optional — named runner tunnels (see Runner tunnels) |
No PAT needed — the runner job mints an installation token via actions/create-github-app-token using the app's own creds. One-time manual step: install the simbox GitHub App on TouchTechClub/runner so the token can write there.
Variables:
| Var | Value |
|---|---|
CLOUDFLARE_ACCOUNT_ID |
CF dashboard → account ID |
APP_URL |
https://simbox.touchtech.club (or workers.dev URL until DNS lands) |
TUNNEL_ZONE |
optional — runner tunnel zone, default simboxtouchtech.dpdns.org |
npmjs.com → @touchtechclub/simbox package → Settings → Trusted Publisher:
- Org:
TouchTechClub, repo:simbox, workflow:release.yml, environment:npm
- CF token:
Workers Editorrole alone cannot create worker scripts (PUT /workers/scripts→ 403 while D1/KV succeed). Use the Edit Cloudflare Workers template or addWorkers Scripts → Editexplicitly. - State-store 401:
~/.alchemy/credentials/default/cloudflare-state-store.jsonmust containaccountIdmatchingCLOUDFLARE_ACCOUNT_ID. Creds minted beforeaccountIdexisted (or for another account) trigger a re-derive that 401s on the state-store worker's refresh endpoint — delete/invalidate them (addaccountId) or bootstrap fresh. - vite ≥ 8:
@alchemy.run/cloudflare-runtimeextendsvite.DevEnvironment(vite 8 only). Ifbun installleaves a stale per-packagevitesymlink innode_modules/.bun/<pkg>/node_modules/, delete it and reinstall. _redirects: don't ship a Pages-style/* /index.html 200file — workers static-assets validation rejects it as an infinite loop.notFoundHandling: "single-page-application"inalchemy.run.tsalready provides SPA fallback.- Pinned resource names are load-bearing: Alchemy's auto-generated physical names embed the deploying OS username (plus stage + a random instanceId). Deploying the same stack from a different identity — e.g.
ubuntulocally vsrunnerin CI — forks a parallel stack: new workers, and a fresh empty D1/KV, plus orphaned state records undersimbox/live_<user>/. That's whyalchemy.run.tspinsname/title(simbox-api,simbox-web,simbox-db,simbox-kv) and managesdomainon the workers. Never unpin or rename them — rename = resource replace (D1 data loss). - Renaming an existing auto-named resource: the old worker still holds the custom domain, and alchemy refuses to steal it — delete the old worker script first (
DELETE /workers/scripts/:id), then redeploy. After a failed deploy,alchemy statemay believe a resource exists that was rolled back; check withalchemy state read <path>andstate deletethe stale record before redeploying (hit this withsimbox-db).
| Var | Source | Purpose |
|---|---|---|
GITHUB_APP_ID |
App settings | installation tokens |
GITHUB_APP_PRIVATE_KEY |
App → private key (PEM) | App JWT signing |
GITHUB_WEBHOOK_SECRET |
you generate | X-Hub-Signature-256 verify |
GITHUB_CLIENT_ID |
App → OAuth | better-auth github provider |
GITHUB_CLIENT_SECRET |
App → client secrets | token exchange |
BETTER_AUTH_SECRET |
openssl rand -base64 32 |
session signing |
APP_URL |
web origin | CORS + trustedOrigins + redirects |
API_URL |
worker URL (auto) | better-auth baseURL |
DEMO_MODE |
— | dev-only seeding, false in prod |
CLOUDFLARE_ACCOUNT_ID |
CI variable | owner of runner tunnels |
CLOUDFLARE_TUNNEL_API_TOKEN |
CI secret (optional) | named runner tunnels |
TUNNEL_ZONE |
CI variable (optional) | runner hostname zone |
Bindings (auto-provisioned by alchemy): DB (D1), KV.