Skip to content

Commit ba33b8d

Browse files
committed
feat(deploy): replace TODO placeholders with Fly.io and Vercel targets
1 parent 09f9580 commit ba33b8d

6 files changed

Lines changed: 229 additions & 19 deletions

File tree

.github/workflows/deploy.yml

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,12 @@ jobs:
3838
steps:
3939
- uses: actions/checkout@v4
4040

41-
- uses: oven-sh/setup-bun@v2
42-
with:
43-
bun-version: latest
44-
45-
- run: bun install --frozen-lockfile
46-
47-
- run: bun run build
41+
- uses: superfly/flyctl-actions/setup-flyctl@master
4842

49-
- name: Deploy API
50-
run: echo "TODO — plug in deployment target (e.g. fly deploy, docker push)"
43+
- name: Deploy API to Fly.io
44+
run: flyctl deploy --config apps/api/fly.toml --dockerfile apps/api/Dockerfile --remote-only
45+
env:
46+
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
5147

5248
deploy-web:
5349
name: Deploy Web
@@ -64,7 +60,13 @@ jobs:
6460

6561
- run: bun install --frozen-lockfile
6662

67-
- run: bun run build
63+
- name: Build web
64+
run: bun run --filter @sandchest/web build
6865

69-
- name: Deploy Web
70-
run: echo "TODO — plug in deployment target (e.g. Vercel, Cloudflare Pages)"
66+
- name: Deploy to Vercel
67+
run: npx vercel deploy --prod --token $VERCEL_TOKEN
68+
working-directory: apps/web
69+
env:
70+
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
71+
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
72+
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}

apps/api/.dockerignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
node_modules
2+
dist
3+
.turbo
4+
*.tsbuildinfo
5+
.env
6+
.env.*

apps/api/Dockerfile

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
FROM oven/bun:1 AS deps
2+
WORKDIR /app
3+
4+
COPY package.json bun.lock ./
5+
COPY apps/api/package.json apps/api/
6+
COPY packages/contract/package.json packages/contract/
7+
COPY packages/db/package.json packages/db/
8+
COPY packages/config/package.json packages/config/
9+
RUN bun install --frozen-lockfile
10+
11+
FROM deps AS build
12+
COPY . .
13+
RUN bun run --filter @sandchest/contract build && \
14+
bun run --filter @sandchest/api build
15+
16+
FROM oven/bun:1-slim
17+
WORKDIR /app
18+
19+
ENV NODE_ENV=production
20+
21+
COPY package.json bun.lock ./
22+
COPY apps/api/package.json apps/api/
23+
COPY packages/contract/package.json packages/contract/
24+
COPY packages/db/package.json packages/db/
25+
COPY packages/config/package.json packages/config/
26+
RUN bun install --frozen-lockfile --production
27+
28+
COPY --from=build /app/apps/api/dist apps/api/dist
29+
COPY --from=build /app/packages/contract/dist packages/contract/dist
30+
COPY --from=build /app/packages/db/src packages/db/src
31+
32+
EXPOSE 3001
33+
CMD ["bun", "run", "apps/api/dist/index.js"]

apps/api/fly.toml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
app = "sandchest-api"
2+
primary_region = "iad"
3+
4+
[build]
5+
dockerfile = "Dockerfile"
6+
ignorefile = ".dockerignore"
7+
8+
[env]
9+
NODE_ENV = "production"
10+
PORT = "3001"
11+
12+
[http_service]
13+
internal_port = 3001
14+
force_https = true
15+
auto_stop_machines = "suspend"
16+
auto_start_machines = true
17+
min_machines_running = 1
18+
19+
[http_service.concurrency]
20+
type = "requests"
21+
hard_limit = 250
22+
soft_limit = 200
23+
24+
[[http_service.checks]]
25+
grace_period = "10s"
26+
interval = "15s"
27+
method = "GET"
28+
path = "/healthz"
29+
timeout = "5s"
30+
31+
[[vm]]
32+
size = "shared-cpu-1x"
33+
memory = "512mb"

apps/api/src/deploy.test.ts

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import { describe, test, expect } from 'bun:test'
2+
import { readFileSync } from 'node:fs'
3+
import { resolve } from 'node:path'
4+
5+
const ROOT = resolve(import.meta.dirname, '..', '..', '..')
6+
const API_DIR = resolve(ROOT, 'apps', 'api')
7+
8+
function readFile(path: string): string {
9+
return readFileSync(resolve(ROOT, path), 'utf-8')
10+
}
11+
12+
describe('deploy.yml', () => {
13+
const workflow = readFile('.github/workflows/deploy.yml')
14+
15+
test('triggers on push to main and manual dispatch', () => {
16+
expect(workflow).toContain('branches: [main]')
17+
expect(workflow).toContain('workflow_dispatch')
18+
})
19+
20+
test('has migrate job that runs before deploys', () => {
21+
expect(workflow).toContain('migrate:')
22+
expect(workflow).toContain('Run database migrations')
23+
expect(workflow).toContain('bun run db:migrate:run')
24+
expect(workflow).toContain('DATABASE_URL')
25+
})
26+
27+
test('has deploy-api job using Fly.io', () => {
28+
expect(workflow).toContain('deploy-api:')
29+
expect(workflow).toContain('needs: [migrate]')
30+
expect(workflow).toContain('superfly/flyctl-actions/setup-flyctl')
31+
expect(workflow).toContain('flyctl deploy')
32+
expect(workflow).toContain('FLY_API_TOKEN')
33+
})
34+
35+
test('deploy-api references correct Dockerfile and fly.toml paths', () => {
36+
expect(workflow).toContain('--config apps/api/fly.toml')
37+
expect(workflow).toContain('--dockerfile apps/api/Dockerfile')
38+
})
39+
40+
test('has deploy-web job using Vercel', () => {
41+
expect(workflow).toContain('deploy-web:')
42+
expect(workflow).toContain('vercel deploy --prod')
43+
expect(workflow).toContain('VERCEL_TOKEN')
44+
expect(workflow).toContain('VERCEL_ORG_ID')
45+
expect(workflow).toContain('VERCEL_PROJECT_ID')
46+
})
47+
48+
test('both deploy jobs depend on migrate', () => {
49+
const apiNeeds = workflow.match(/deploy-api:[\s\S]*?needs:\s*\[migrate\]/)
50+
const webNeeds = workflow.match(/deploy-web:[\s\S]*?needs:\s*\[migrate\]/)
51+
expect(apiNeeds).not.toBeNull()
52+
expect(webNeeds).not.toBeNull()
53+
})
54+
55+
test('all jobs use production environment', () => {
56+
const envMatches = workflow.match(/environment:\s*production/g)
57+
expect(envMatches).not.toBeNull()
58+
expect(envMatches!.length).toBeGreaterThanOrEqual(3)
59+
})
60+
61+
test('has no TODO placeholders', () => {
62+
expect(workflow).not.toContain('TODO')
63+
expect(workflow).not.toMatch(/echo\s+["']TODO/)
64+
})
65+
})
66+
67+
describe('Dockerfile', () => {
68+
const dockerfile = readFileSync(resolve(API_DIR, 'Dockerfile'), 'utf-8')
69+
70+
test('uses multi-stage build', () => {
71+
const fromStatements = dockerfile.match(/^FROM\s/gm)
72+
expect(fromStatements).not.toBeNull()
73+
expect(fromStatements!.length).toBeGreaterThanOrEqual(3)
74+
})
75+
76+
test('uses official Bun image', () => {
77+
expect(dockerfile).toContain('FROM oven/bun:1')
78+
})
79+
80+
test('installs dependencies with frozen lockfile', () => {
81+
expect(dockerfile).toContain('bun install --frozen-lockfile')
82+
})
83+
84+
test('builds contract and api packages', () => {
85+
expect(dockerfile).toContain('--filter @sandchest/contract build')
86+
expect(dockerfile).toContain('--filter @sandchest/api build')
87+
})
88+
89+
test('production stage uses slim image', () => {
90+
expect(dockerfile).toContain('FROM oven/bun:1-slim')
91+
})
92+
93+
test('sets NODE_ENV to production', () => {
94+
expect(dockerfile).toContain('ENV NODE_ENV=production')
95+
})
96+
97+
test('exposes correct port', () => {
98+
expect(dockerfile).toContain('EXPOSE 3001')
99+
})
100+
101+
test('runs compiled output', () => {
102+
expect(dockerfile).toContain('apps/api/dist/index.js')
103+
})
104+
105+
test('copies workspace package.json files for dependency install', () => {
106+
expect(dockerfile).toContain('COPY packages/contract/package.json')
107+
expect(dockerfile).toContain('COPY packages/db/package.json')
108+
})
109+
})
110+
111+
describe('fly.toml', () => {
112+
const flytoml = readFileSync(resolve(API_DIR, 'fly.toml'), 'utf-8')
113+
114+
test('sets app name', () => {
115+
expect(flytoml).toContain('app = "sandchest-api"')
116+
})
117+
118+
test('configures HTTP service on port 3001', () => {
119+
expect(flytoml).toContain('internal_port = 3001')
120+
expect(flytoml).toContain('force_https = true')
121+
})
122+
123+
test('has health check on /healthz', () => {
124+
expect(flytoml).toContain('path = "/healthz"')
125+
})
126+
127+
test('configures auto-start and minimum machines', () => {
128+
expect(flytoml).toContain('auto_start_machines = true')
129+
expect(flytoml).toContain('min_machines_running = 1')
130+
})
131+
132+
test('references correct Dockerfile', () => {
133+
expect(flytoml).toContain('dockerfile = "Dockerfile"')
134+
})
135+
})

apps/api/src/workers/audit.test.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,11 @@ describe('phase 9 gap audit', () => {
3333
}
3434
})
3535

36-
test('deploy.yml contains TODO placeholders', () => {
36+
test('deploy.yml has real deployment targets', () => {
3737
const src = readFileSync(resolve(ROOT, '.github/workflows/deploy.yml'), 'utf-8')
38-
expect(src).toContain('TODO')
38+
expect(src).not.toContain('TODO')
39+
expect(src).toContain('flyctl deploy')
40+
expect(src).toContain('vercel deploy --prod')
3941
})
4042

4143
test('Python SDK exists', () => {
@@ -69,16 +71,15 @@ describe('phase 9 gap audit', () => {
6971
expect(src).toContain('action')
7072
})
7173

72-
test('MCP has exactly 9 tools (5 missing)', () => {
74+
test('MCP has all 14 tools', () => {
7375
const src = readFileSync(resolve(ROOT, 'packages/mcp/src/tools.ts'), 'utf-8')
7476
const toolCount = (src.match(/registerTool\(/g) ?? []).length
75-
expect(toolCount).toBe(9)
77+
expect(toolCount).toBe(14)
7678
})
7779

78-
test('no create sandbox dialog in dashboard', () => {
80+
test('dashboard has create sandbox dialog', () => {
7981
const src = readFileSync(resolve(ROOT, 'apps/web/src/components/dashboard/SandboxList.tsx'), 'utf-8')
80-
expect(src).not.toContain('CreateSandbox')
81-
expect(src).not.toContain('create sandbox')
82+
expect(src).toContain('CreateSandbox')
8283
})
8384

8485
test('no OpenTelemetry packages installed', () => {

0 commit comments

Comments
 (0)