Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
Empty file.
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ dist
**/coverage
.playwright
.playwright-cli
.dev

apps/web
# `bun ci` reads every workspace member's package.json to validate the lockfile,
Expand Down
12 changes: 12 additions & 0 deletions .github/scripts/sync-deploy-scaffold.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail

: "${DEPLOY_SSH_HOST:?DEPLOY_SSH_HOST is required}"
: "${DEPLOY_SSH_USER:?DEPLOY_SSH_USER is required}"

DEST="${DEPLOY_PATH:-/opt/apps/snapsplit}"

echo "Syncing scaffold to $DEPLOY_SSH_USER@$DEPLOY_SSH_HOST:$DEST"
ssh -o BatchMode=yes "$DEPLOY_SSH_USER@$DEPLOY_SSH_HOST" "mkdir -p $DEST"
scp -o BatchMode=yes apps/api/deploy/compose.yaml apps/api/deploy/deploy-on-host.sh apps/api/deploy/ssh-wrapper.sh apps/api/deploy/.env.example "$DEPLOY_SSH_USER@$DEPLOY_SSH_HOST:$DEST/"
ssh -o BatchMode=yes "$DEPLOY_SSH_USER@$DEPLOY_SSH_HOST" "chmod +x $DEST/deploy-on-host.sh $DEST/ssh-wrapper.sh; chown -R deployer:deployer $DEST 2>/dev/null || chown -R \$USER:\$USER $DEST || true; ls -l $DEST"
62 changes: 60 additions & 2 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@ concurrency:

permissions:
contents: read
packages: write

jobs:
publish:
name: Publish affected images
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
env:
NX_BASE: ${{ inputs.nx-base }}
NX_HEAD: ${{ inputs.nx-head }}
Expand Down Expand Up @@ -63,5 +65,61 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Publish container images (affected)
- name: Publish affected projects
run: bunx nx affected -t publish

deploy:
name: Deploy affected projects
needs: publish
runs-on: ubuntu-latest
env:
NX_BASE: ${{ inputs.nx-base }}
NX_HEAD: ${{ inputs.nx-head }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
VITE_API_URL: ${{ vars.VITE_API_URL }}
VITE_SITE_URL: ${{ vars.VITE_SITE_URL }}
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
steps:
- uses: actions/checkout@v7
with:
filter: tree:0
fetch-depth: 0

- name: Setup toolchain (mise)
uses: jdx/mise-action@v4

- name: Get Bun cache dir
id: bun-cache-dir
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"

- name: Restore Bun cache
uses: actions/cache/restore@v6
with:
path: ${{ steps.bun-cache-dir.outputs.dir }}
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
restore-keys: |
${{ runner.os }}-bun-

- name: Install dependencies
run: bun ci

- name: Configure SSH for the API host
if: vars.DEPLOY_SSH_HOST != ''
uses: shimataro/ssh-key-action@v2.8.1
with:
key: ${{ secrets.DEPLOY_SSH_KEY }}
known_hosts: ${{ vars.DEPLOY_SSH_KNOWN_HOSTS }}

- name: Sync deploy scaffold to VM
if: vars.DEPLOY_SSH_HOST != ''
env:
DEPLOY_SSH_HOST: ${{ vars.DEPLOY_SSH_HOST }}
DEPLOY_SSH_USER: ${{ vars.DEPLOY_SSH_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
run: bash .github/scripts/sync-deploy-scaffold.sh

- name: Deploy affected projects
run: bunx nx affected -t deploy
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down Expand Up @@ -69,10 +71,11 @@ jobs:

cd:
needs: ci
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
permissions:
contents: read
packages: write
secrets: inherit
uses: ./.github/workflows/cd.yml
with:
nx-base: ${{ needs.ci.outputs.nx-base }}
Expand Down
11 changes: 11 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ node_modules
coverage
*.tsbuildinfo

# Wrangler
.wrangler

# Vite / Vitest
vite.config.*.timestamp*
vitest.config.*.timestamp*
Expand Down Expand Up @@ -50,3 +53,11 @@ Thumbs.db
# Playwright
.playwright
.playwright-cli

# Terraform
iac/.terraform/
iac/*.tfstate
iac/*.tfstate.*
iac/*.tfvars
!iac/*.tfvars.example
iac/backend.hcl
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ The API has two test targets:
- `bun nx test api` — unit tests over `apps/api/src`. Cached, no external services.
- `bun nx test:integration api` — integration tests over `apps/api/test`. **Requires a
reachable Docker socket**: `apps/api/test/setup.ts` boots two testcontainers for the run,
`mongo:7.0` and the `fsouza/fake-gcs-server` emulator, so the suite exercises the real
`GcsObjectStorage` instead of a fake. Without Docker the suite fails while starting the
`mongo:7.0` and MinIO, so the suite exercises the real `S3ObjectStorage` against a real
S3 API instead of a fake. Without Docker the suite fails while starting the
containers. Caching is disabled, and the first run pulls both images, so allow up to the
180s startup budget.

Integration specs get their storage from `testStorage()` in `apps/api/test/setup.ts`, which
points `createReceiptStorage` at the emulator and its per-run bucket. Only the AI extraction
points `createReceiptStorage` at MinIO and its per-run bucket. Only the AI extraction
stays faked. Unit specs under `src` keep their own in-memory fakes — that target must stay
cached and free of external services.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ bun nx test:integration api
```

It boots two [testcontainers](https://testcontainers.com) for the run
`mongo:7.0` and the `fsouza/fake-gcs-server` emulator — so it needs a reachable
`mongo:7.0` and MinIO — so it needs a reachable
Docker socket; without one it fails while starting the containers rather than
on an assertion. The first run pulls both images, which is why startup allows
180s. Neither `bun test` nor `bun nx run-many -t test` includes this target, so
Expand Down
15 changes: 7 additions & 8 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Port the API listens on. Defaults to 3000.
# PORT=3000
MONGODB_URI=mongodb://root:example@localhost:27017/snapsplit?authSource=admin
# MongoDB database name. Defaults to "snapsplit".
# DATABASE_NAME=snapsplit
GOOGLE_GENERATIVE_AI_API_KEY=your-gemini-api-key
# Vision model for receipt extraction. Defaults to gemini-3.1-flash-lite.
# GEMINI_MODEL=gemini-3.1-flash-lite
Expand All @@ -9,9 +11,11 @@ GOOGLE_GENERATIVE_AI_API_KEY=your-gemini-api-key
# How far (in cents) the line sums may drift from the printed total before an
# extraction counts as inconsistent and is retried. Defaults to 50.
# RECEIPT_SUM_TOLERANCE_CENTS=50
# In dev this bucket is seeded by the emulator from .dev/gcs/ (compose.yaml);
# the app never creates buckets itself. In production it is provisioned by infra.
GCS_BUCKET=snapsplit-receipts
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_BUCKET=snapsplit-receipts
S3_ACCESS_KEY_ID=minioadmin
S3_SECRET_ACCESS_KEY=minioadmin
# Set to "production" in deployed environments (Bun defaults it to
# "development"). With NODE_ENV=production an unset CORS_ORIGIN is a startup
# error.
Expand All @@ -30,8 +34,3 @@ GCS_BUCKET=snapsplit-receipts
# caller supplied, so a guesser rotates it per request and the limit stops
# applying at all.
# TRUST_PROXY=1
# Dev only: point the GCS client at the fake-gcs-server emulator (compose.yaml).
# Leave unset in production so the client uses real GCS via ADC.
# NOTE: intentionally NOT the SDK-reserved STORAGE_EMULATOR_HOST — that name
# triggers a conflicting auto-emulator mode that 404s against fake-gcs.
GCS_EMULATOR_HOST=http://localhost:4443
17 changes: 17 additions & 0 deletions apps/api/deploy/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
API_IMAGE_TAG=latest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stripping the comments leaves this file undocumented anywhere in the repo — docs/deployment.md is gone, README.md has no deploy section, and docs/ now holds only dcu.puml and srs.md. This is the file a human copies to /opt/apps/snapsplit/.env and fills in by hand, and the removed lines carried precisely the non-obvious parts: that CORS_ORIGIN is the Cloudflare Worker origin rather than the API's own domain, why TRUST_PROXY=1 is required (without it the rate limiter keys every caller by Caddy's address and throttles them as one), and the terraform output commands that source the five S3_* values. Note apps/api/.env.example kept its comments, so the two files are now inconsistent.


DUCKDNS_DOMAIN=your-subdomain.duckdns.org

NODE_ENV=production
TRUST_PROXY=1
CORS_ORIGIN=https://example.workers.dev
GOOGLE_GENERATIVE_AI_API_KEY=your-gemini-api-key

MONGODB_URI=mongodb+srv://<user>:<pass>@<cluster>.mongodb.net/snapsplit?retryWrites=true&w=majority
DATABASE_NAME=snapsplit

S3_ENDPOINT=https://<namespace>.compat.objectstorage.<region>.oraclecloud.com
S3_REGION=<region>
S3_BUCKET=snapsplit-receipts
S3_ACCESS_KEY_ID=<s3_access_key_id>
S3_SECRET_ACCESS_KEY=<s3_secret_access_key>
19 changes: 19 additions & 0 deletions apps/api/deploy/compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
services:
api:
image: ghcr.io/aek676/snapsplit-api:${API_IMAGE_TAG:?run apps/api/scripts/deploy.sh, it sets this}
restart: unless-stopped
expose:
- 3000
env_file: .env
environment:
TRUST_PROXY: 1
networks:
- appnet
labels:
caddy: "snapsplit-api.${DUCKDNS_DOMAIN}"
caddy.reverse_proxy: "{{upstreams 3000}}"

networks:
appnet:
external: true
name: appnet
25 changes: 25 additions & 0 deletions apps/api/deploy/deploy-on-host.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env sh
set -eu

tag="${SSH_ORIGINAL_COMMAND:-}"
case "$tag" in
sha-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]) ;;
*)
echo "deploy: expected a sha-xxxxxxx image tag, got '$tag'" >&2
exit 1
;;
esac

cd "$(dirname "$0")"
[ -f compose.yaml ] || { echo "deploy: no compose.yaml next to $0" >&2; exit 1; }
[ -f .env ] || { echo "deploy: no .env next to $0 — the API needs MONGODB_URI, CORS_ORIGIN, S3_BUCKET…" >&2; exit 1; }

if grep -q '^API_IMAGE_TAG=' .env; then
sed -i "s|^API_IMAGE_TAG=.*|API_IMAGE_TAG=$tag|" .env
else
printf 'API_IMAGE_TAG=%s\n' "$tag" >>.env
fi

docker compose pull api
docker compose up -d api
docker image prune -f
40 changes: 40 additions & 0 deletions apps/api/deploy/ssh-wrapper.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/bin/sh
set -eu
cmd="${SSH_ORIGINAL_COMMAND:-}"
# 1. deploy tag sha-xxxxxxx
case "$cmd" in
sha-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
exec /opt/apps/snapsplit/deploy-on-host.sh
;;
esac
# 2. sync: mkdir
case "$cmd" in
*mkdir*"/opt/apps/snapsplit"*)
exec sh -c "$cmd"
;;
esac
# 3. sync: scp -t (old) and sftp-server (new scp/sftp via SFTP)
case "$cmd" in
scp\ -t*"/opt/apps/snapsplit"*)
exec sh -c "$cmd"
;;
scp*)
case "$cmd" in
*"/opt/apps/snapsplit"*) exec sh -c "$cmd" ;;
esac
;;
*sftp-server*)
exec sh -c "$cmd"
;;
esac
# 4. sync: chmod +x + chown + ls
case "$cmd" in
*chmod*"/opt/apps/snapsplit/deploy-on-host.sh"*ls\ -l*"/opt/apps/snapsplit"*)
exec sh -c "$cmd"
;;
*chmod*"/opt/apps/snapsplit/ssh-wrapper.sh"*ls\ -l*"/opt/apps/snapsplit"*)
exec sh -c "$cmd"
;;
esac
echo "ssh-wrapper: rejected command '$cmd'" >&2
exit 1
1 change: 0 additions & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
"@ai-sdk/google": "^4.0.43",
"@elysiajs/cors": "^1.4.2",
"@elysiajs/openapi": "^1.4.15",
"@google-cloud/storage": "^7.22.0",
"@repo/shared-types": "workspace:*",
"@repo/split-logic": "workspace:*",
"ai": "^7.0.64",
Expand Down
7 changes: 7 additions & 0 deletions apps/api/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@
"command": "sh apps/api/scripts/publish.sh"
}
},
"deploy": {
"executor": "nx:run-commands",
"cache": false,
"options": {
"command": "sh apps/api/scripts/deploy.sh"
}
},
"lint": {
"executor": "nx:run-commands",
"cache": true,
Expand Down
10 changes: 10 additions & 0 deletions apps/api/scripts/deploy.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
#!/usr/bin/env sh
set -eu

: "${DEPLOY_SSH_HOST:?expected the VM running the API}"
: "${DEPLOY_SSH_USER:?expected the SSH user on that VM}"

sha="${GITHUB_SHA:-$(git rev-parse HEAD)}"
tag="sha-$(printf '%.7s' "$sha")"

exec ssh -o BatchMode=yes "$DEPLOY_SSH_USER@$DEPLOY_SSH_HOST" "$tag"
2 changes: 2 additions & 0 deletions apps/api/scripts/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ exec docker buildx build \
--file apps/api/Dockerfile \
--target prebuilt \
--platform linux/amd64,linux/arm64 \
--cache-from "type=registry,ref=$image:buildcache" \
--cache-to "type=registry,ref=$image:buildcache,mode=max" \
Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prebuilt target has nothing to cache. In apps/api/Dockerfile, prebuilt is FROM runtime (distroless/base-debian13) plus a single COPY dist/apps/api-linux/${TARGETARCH}/server — the deps and build stages aren't in that graph at all, because the cross-compile already happened outside Docker in the build:linux-amd64/build:linux-arm64 targets that publish depends on. The only non-base layer is the compiled binary, whose digest changes on exactly the commits that make api affected, so --cache-from can never hit; meanwhile --cache-to defaults to ignore-error=false, so a GHCR cache-export hiccup fails publish after the image has already pushed and blocks the deploy job that needs it.

Technical details
# Registry buildcache is unreachable for `--target prebuilt`

## Affected sites
- `apps/api/scripts/publish.sh:18-19` — `--cache-from` / `--cache-to type=registry,ref=$image:buildcache,mode=max`, applied to a `docker buildx build --target prebuilt`.
- `apps/api/Dockerfile` — `prebuilt` is `FROM runtime`, which is `FROM gcr.io/distroless/base-debian13:nonroot`. BuildKit only builds stages reachable from `--target`, so `deps` (`bun ci`) and `build` (`compile.sh`) are never instantiated.
- `apps/api/project.json` — `publish` `dependsOn` `build:linux-amd64` and `build:linux-arm64`, both nx-cached, both running `bun build --compile` on the host.

## Required outcome
- CD does not carry cache flags that cannot yield a hit, and a cache-export failure cannot fail a publish whose image push already succeeded.

## Suggested approach (optional)
- Simplest: drop both flags. The expensive work is already memoised by the nx cache on `build:linux-*`, and the real Docker build cost lives in the `hermetic` target, which `publish.sh` does not use.
- If you want to keep them as a hedge, at minimum set `ignore-error=true` on `--cache-to` and drop `mode=max` to `mode=min` — with no intermediate stages in the graph, `max` buys nothing over `min`.

## Open questions for the human
- Was the intent to speed up `hermetic` (which does run `bun ci` + `compile.sh` in-image) rather than `prebuilt`? If CI is meant to move to `hermetic`, the cache flags make sense there and this comment becomes a sequencing note instead.

"$@" \
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
Expand Down
7 changes: 5 additions & 2 deletions apps/api/src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ declare module 'bun' {
GOOGLE_GENERATIVE_AI_API_KEY: string;
GEMINI_MODEL?: string;
RECEIPT_EXTRACTION_MAX_ATTEMPTS?: string;
GCS_BUCKET: string;
GCS_EMULATOR_HOST?: string;
S3_BUCKET: string;
S3_ENDPOINT: string;
S3_ACCESS_KEY_ID: string;
S3_SECRET_ACCESS_KEY: string;
S3_REGION?: string;
TRUST_PROXY?: string;
CORS_ORIGIN?: string;
NODE_ENV?: string;
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/modules/session/__test__/service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ describe('SessionService.deleteSession', () => {
);
const storage = fakeStorage({
delete: mock(async () => {
throw new Error('gcs down');
throw new Error('storage down');
}),
});

Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/schemas/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ sessionSchema.index(
{ unique: true, partialFilterExpression: { code: { $type: 'string' } } },
);
sessionSchema.index({ 'participants.deviceTokenHash': 1 });
// NOTE: 90 days — keep in sync with the GCS lifecycle rule (docs/deployment.md).
// NOTE: 90 days — keep in sync with the bucket lifecycle rule in iac/
sessionSchema.index({ createdAt: 1 }, { expireAfterSeconds: 90 * 24 * 3600 });

export type Session = InferSchemaType<typeof sessionSchema>;
Expand Down
Loading