Deployment
Automatic deploys
GitHub Actions deploy via Serverless Framework:
| Branch / trigger | Workflow | Stage |
|---|---|---|
develop push |
.github/workflows/deploy-dev.js.yml |
dev |
main push |
.github/workflows/deploy-prod.js.yml |
prod |
repository_dispatch dep_update_dev |
deploy-dev | dev |
repository_dispatch dep_update_prod |
deploy-prod | prod |
Downstream repos (e.g. gameslib) can trigger backend redeploys after package publishes.
Each deploy runs two Serverless stacks (API first, then crons):
| Stack | Service name | Deploy (CI) |
|---|---|---|
| API / WebSocket | abstract-play |
bash bin/serverless-deploy.sh <stage> <profile> |
| Scheduled jobs | abstract-play-backend-crons |
bash crons/scripts/serverless-deploy.sh <stage> |
The former backend-crons repository is archived; deploy and develop only from this repo’s crons/ workspace.
Crons source lives in crons/. See Crons deployment.
AP dependency pins (ci-deps.*.json)
Canonical pins live in ci-deps.dev.json and ci-deps.prod.json. CI runs npm ci → manifest validation → ap-install-deps --stage dev|prod → strict lockfile check → build/test.
After merging develop into main (or another branch that changes dependency files), run npm run sync-deps:prod on main or npm run sync-deps on develop, then commit if anything changed. Do not hand-merge AP version strings in package.json.
npm run sync-deps runs ap-install-deps at the repo root, copies AP pins into crons/package.json from the root manifest, then prunes any stale crons/node_modules/@abstractplay tree. Deploy/Test workflows run scripts/sync-crons-ap-deps.mjs before npm ci so a stale crons/package.json (for example after a cross-branch merge with merge=ours) cannot break install. The gameslib relay auto-commit on dep_update_dev / dep_update_prod also stages crons/package.json with the root manifests. postinstall re-runs sync after install; npm run lint runs check-crons-ap-pins so committed pins cannot drift from root. Locally after a merge, prefer npm run ci:install (or ci:install:prod on main) instead of bare npm ci if install fails on workspace pin mismatch.
.gitattributes uses merge=ours for ci-deps.prod.json on main, ci-deps.dev.json on develop, and for package.json, package-lock.json, and crons/package.json on any cross-branch merge — the branch you merge into keeps its pins until you run sync-deps. Do not resolve AP version conflicts by picking lines from the other branch.
Prod deploys may fail at build when code on main uses a gameslib API not yet in the prod pin — wait for dep_update_prod or bump ci-deps.prod.json when releasing.
Lambda module-load checks (CI)
Deploy workflows run npm test after npm run build. pretest runs npm run build:lambda-bundles, which esbuilds every Lambda handler entry with the same options as serverless-esbuild (ESM .mjs, same external list). vitest run executes unit tests from test/**/*.test.ts. test/lambdaInit.test.mjs dynamically imports those bundles plus @abstractplay/gameslib — matching Lambda cold-start module loading.
If vitest fails with exports is not defined loading lib/*.js, delete stale local tsc sidecars (lib/*.js, utils/*.js) left over from before the ESM flip — they are gitignored and must not remain on disk beside the .ts sources.
Handlers are packaged with serverless-esbuild (ESM .mjs bundles). Heavy @abstractplay/* dependencies live in a shared Lambda layer built by npm run build:layers before serverless package / deploy.
CJS packages that use dynamic require() (e.g. web-push, @sunknudsen/totp, i18next) must stay in the esbuild external list so they load from node_modules at runtime — bundling them into ESM output causes init errors like Dynamic require of "crypto" is not supported.
Manual deploy
With AWS profiles configured:
npm run build
npx serverless deploy # dev (default stage)
npx serverless --stage prod deploy # prod
Or use npm scripts: npm run deploy-dev, npm run deploy-prod, npm run full-dev, npm run full-prod (they invoke the pinned serverless devDependency).
Serverless v4 and secrets
Serverless Framework v4 resolves provider.environment when you run serverless package, print, or deploy. CI and local deploys use the pinned serverless@4.42.0 devDependency via npx serverless (no global Serverless install). The Dashboard org is abstractplay (SERVERLESS_ORG in CI; org: in serverless.yml).
- CI: GitHub Actions set
SERVERLESS_ACCESS_KEY(org secret),SERVERLESS_ORG=abstractplay, plusTOTP_KEY, VAPID keys, andOPENSSH_PRIVATE_KEYfrom repository secrets. - Local: Keep values in
../apsecrets.yml(sibling of this repo, e.g.ap/apsecrets.ymlnext tonode-backend/). That path is outside this git repository, so it cannot be committed here.serverless.ymluses${env:VAR, file(../apsecrets.yml):key}— CI environment variables win when set; otherwise the CLI reads the file.
Expected keys in ../apsecrets.yml: totp_key, vapid_private_key, vapid_public_key, openssh_private_key, announcements_discord_webhook_url (YAML snake_case; openssh_private_key is OpenSSH PEM text for bot webhook signing). For CI prod deploy, also set GitHub secret ANNOUNCEMENTS_DISCORD_WEBHOOK_URL (same webhook URL).
Stage configuration
Per-stage settings live in serverless.yml under custom.stageConfig:
- Cognito user pool and app client (human players)
- Bot Cognito pool, token URL, OAuth scope
- SQS URLs (AiAi queue, WebSocket messages, bot outbound)
- WebSocket API domain
Table name: abstract-play-${stage}.
gameProjector stream (automatic in CI)
The DynamoDB table must have streams enabled before CloudFormation can reference StreamArn. The stream Lambda trigger is gated by the enableGameProjectorStream deploy parameter.
CI (bin/serverless-deploy.sh) checks LatestStreamArn on the stage table before deploy:
- No stream yet →
enableGameProjectorStream=false(enablesStreamSpecificationon the table; deploysgameProjectorLambda without a trigger). - Stream exists →
enableGameProjectorStream=true(createsGameProjectorEventSourceMapping+ DLQ wiring).
So the first deploy to a new stage only enables streams. The next CI run on that stage attaches the mapping. No manual flags.
Local manual deploy (same two-step logic):
npm run build
bash bin/serverless-deploy.sh dev AbstractPlayDev
# After first run succeeds, any later run passes enableGameProjectorStream=true automatically.
Once a stage has a stream, always deploy with true (or use the script) so CloudFormation does not remove the event source mapping.
Ops alerts (email)
When OPS_ALERT_EMAIL is set at deploy time, CloudFormation creates an SNS topic (abstractplay-ops-alerts-${stage}) and wires gameProjector alarms to it:
| Alarm | Signal |
|---|---|
abstractplay-game-projector-errors-${stage} |
Lambda Errors ≥ 1 in 1 minute (catches init crashes) |
abstractplay-game-projector-dlq-${stage} |
DLQ depth ≥ 1 message |
abstractplay-game-projector-iterator-age-${stage} |
Stream IteratorAge > 5 minutes for 10 minutes |
First deploy: SNS sends a subscription confirmation email — you must click Confirm subscription once or alarms will not arrive.
Local / manual deploy:
export OPS_ALERT_EMAIL=you@example.com
bash bin/serverless-deploy.sh prod AbstractPlayProd
Omit OPS_ALERT_EMAIL to skip the topic and alarm actions (dev deploys by default).
Required GitHub secrets
SERVERLESS_ACCESS_KEY— Serverless Dashboard access key (org secretabstractplay)AWS_KEY,AWS_SECRET— deploy credentialsPAT_READ_PACKAGES— npm install from GitHub PackagesVAPID_PUBLIC_KEY,VAPID_PRIVATE_KEY,TOTP_KEY,OPENSSH_PRIVATE_KEYOPS_ALERT_EMAIL— ops CloudWatch alarm notifications (prod workflow)TEST_BOT_CLIENT_ID,TEST_BOT_CLIENT_SECRET(dev workflow)
Cognito setup (essentials)
Each stage needs a Cognito user pool with an app client for the front end:
- Create a user pool (defaults are fine).
- Add an app client — do not generate a client secret.
- Copy the pool ARN into
serverless.yml(custom.stageConfig.{stage}.userpool) for theauthQueryauthorizer. - App client settings: enable identity providers; set callback/sign-out URLs (
http://localhost:3000for local dev;https://play.dev.abstractplay.com/https://play.abstractplay.comfor deployed front ends). - OAuth: Authorization code grant, Implicit grant,
openidscope; enableaws.cognito.signin.user.adminandEmail.
Bot pools are separate per stage — see Bots.
Documentation deploys
When a push to develop or main includes changes under docs/ or crons/docs/, the deploy workflow dispatches dep_update_dev / dep_update_prod to the docs repository so the site rebuilds (after the docs repo vendors this monorepo and syncs /crons/ — see crons/docs/_docs-repo-integration.md).