Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
48 changes: 48 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Local configuration for `make run` and friends. Copy to .env; every value
# here is a safe local default. Production reads the same variables from
# Lambda configuration and Secrets Manager (APP_SECRET_ARN).

APP_ENV=local
LISTEN_ADDR=:8080

# Database: pgx against docker-compose Postgres (`make up`). In AWS,
# DATABASE_BACKEND=dataapi with DB_CLUSTER_ARN / DB_SECRET_ARN / DB_NAME.
DATABASE_BACKEND=pg
DATABASE_URL=postgres://substratal:substratal@localhost:55432/substratal?sslmode=disable

# Public identity. ISSUER is the JWT iss; keep the production value so
# tokens look like production ones.
ISSUER=https://api.substratalapps.com
PUBLIC_BASE_URL=http://localhost:8080
DASHBOARD_URL=http://localhost:3000
AUTHORIZE_URL=https://auth.substratalapps.com/authorize
CORS_ALLOWED_ORIGINS=http://localhost:3000

# Signing and encryption. Empty KMS ids = local keys: an RSA key generated
# into .dev/ on first run, and a fixed development AES key.
KMS_SIGNING_KEY_ID=
KMS_DATA_KEY_ID=
LOCAL_SIGNING_KEY_FILE=.dev/signing-key.pem
CURSOR_SECRET=local-development-cursor-secret

# Email: Mailpit catches everything; read it at http://localhost:58025.
EMAIL_BACKEND=smtp
SMTP_ADDR=localhost:51025
EMAIL_FROM=Substratal <no-reply@mail.substratalapps.com>

# Webhooks: allow http://localhost receivers locally (never in production).
WEBHOOKS_ALLOW_PRIVATE=true

# Stripe test mode (optional; billing endpoints return 503 without it).
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=

# LocalStack, for anything pointed at AWS APIs locally.
AWS_ENDPOINT_URL=http://localhost:4566
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=test
AWS_SECRET_ACCESS_KEY=test

# `make seed` / `make quickstart`
ADMIN_EMAIL=admin@example.com
ADMIN_PASSWORD=local admin passphrase
131 changes: 131 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# CI for every push to dev (and feature/*, hotfix/*) and every PR into main
# (README → Branches, CI, and deployment → What runs on dev). Nothing here
# touches AWS except the read-only terraform plan on PRs into main.
name: CI

on:
push:
branches: [dev, "feature/**", "hotfix/**"]
pull_request:
branches: [main]

permissions:
contents: read

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 3s --health-retries 30
env:
# The API and tests connect as a non-superuser role, as they do
# locally: a superuser bypasses Row-Level Security.
TEST_DATABASE_URL: postgres://substratal:substratal@localhost:5432/postgres?sslmode=disable
DATABASE_URL: postgres://substratal:substratal@localhost:5432/substratal?sslmode=disable
steps:
- uses: actions/checkout@v7

- uses: actions/setup-go@v7
with:
go-version-file: go.mod

- name: Create the database role
env:
PGPASSWORD: postgres
run: psql -h localhost -U postgres -v ON_ERROR_STOP=1 -f docker/postgres-init.sql

- name: Format and vet
run: make fmt-check && go vet ./...

- uses: golangci/golangci-lint-action@v9
with:
version: latest

- name: Generated code is current
run: make gen-check

- name: Unit, integration, and contract tests
run: go test -count=1 -race ./...

- name: Quickstart against the devserver
env:
EMAIL_BACKEND: log
LISTEN_ADDR: ":8080"
ADMIN_EMAIL: ci-admin@example.com
ADMIN_PASSWORD: ci admin passphrase
run: |
go run ./cmd/migrate
go run ./cmd/admin bootstrap -email "$ADMIN_EMAIL"
go build -o /tmp/devserver ./cmd/devserver
/tmp/devserver > /tmp/devserver.log 2>&1 &
for i in $(seq 1 60); do curl -sf localhost:8080/v1/health && break; sleep 0.5; done
SUBSTRATAL_API=http://localhost:8080/v1 scripts/quickstart.sh || (cat /tmp/devserver.log; exit 1)

- name: Build Lambda artifacts
run: make build

terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: hashicorp/setup-terraform@v4
- run: terraform fmt -check -recursive infra/terraform
- run: make tf-validate

docs:
runs-on: ubuntu-latest
defaults:
run:
working-directory: docs
steps:
- uses: actions/checkout@v7
- uses: ruby/setup-ruby@v1
with:
ruby-version: "3.2"
bundler-cache: true
working-directory: docs
- run: bundle exec jekyll build

# The one thing to read before merging a release: exactly what the merge
# will change in AWS. Read-only role; skipped until Phase 0 setup exists.
plan:
if: github.event_name == 'pull_request' && vars.AWS_PLAN_ROLE_ARN != ''
needs: [test, terraform]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_PLAN_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- uses: hashicorp/setup-terraform@v4
- name: terraform plan
id: plan
working-directory: infra/terraform/prod
run: |
terraform init -input=false -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}"
terraform plan -input=false -no-color -lock=false \
-var "artifact_bucket=${{ vars.ARTIFACT_BUCKET }}" -var "release=${{ github.event.pull_request.head.sha }}" \
-var "domain_name=${{ vars.API_DOMAIN }}" > plan.txt 2>&1 || (cat plan.txt; exit 1)
tail -c 60000 plan.txt > plan-short.txt
- uses: actions/github-script@v9
with:
script: |
const fs = require('fs');
const plan = fs.readFileSync('infra/terraform/prod/plan-short.txt', 'utf8');
await github.rest.issues.createComment({ ...context.repo, issue_number: context.issue.number,
body: "### terraform plan (production)\n\nThis is exactly what merging will change in AWS.\n\n<details><summary>Plan</summary>\n\n```\n" + plan + "\n```\n</details>" });
140 changes: 140 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# Every merge into main deploys production, and only that way (README →
# What a merge into main does). The deploy role's OIDC trust policy accepts
# only this repository, the main branch, and the production environment,
# so nothing else can obtain these credentials, whatever its YAML says.
name: Deploy

on:
push:
branches: [main]
paths-ignore: ["docs/**", "**.md", ".github/workflows/docs.yml"]
workflow_dispatch:

permissions:
contents: read

concurrency:
group: production
cancel-in-progress: false

jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_PASSWORD: postgres
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres" --health-interval 2s --health-timeout 3s --health-retries 30
env:
TEST_DATABASE_URL: postgres://substratal:substratal@localhost:5432/postgres?sslmode=disable
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
- env:
PGPASSWORD: postgres
run: psql -h localhost -U postgres -v ON_ERROR_STOP=1 -f docker/postgres-init.sql
# The merge result itself, not just the two sides of it.
- run: make fmt-check && go vet ./... && make gen-check && go test -count=1 ./...

deploy:
needs: test
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
env:
RELEASE: ${{ github.sha }}
steps:
- name: Phase 0 setup exists
id: ready
run: |
if [ -z "${{ vars.AWS_DEPLOY_ROLE_ARN }}" ]; then
echo "::notice::AWS isn't bootstrapped yet (see README → One-time setup); skipping the production deploy."
echo "ready=false" >> "$GITHUB_OUTPUT"
else
echo "ready=true" >> "$GITHUB_OUTPUT"
fi

- if: steps.ready.outputs.ready == 'true'
uses: actions/checkout@v7

- if: steps.ready.outputs.ready == 'true'
uses: actions/setup-go@v7
with:
go-version-file: go.mod

- if: steps.ready.outputs.ready == 'true'
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}

- if: steps.ready.outputs.ready == 'true'
uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false

- name: Build and upload artifacts
if: steps.ready.outputs.ready == 'true'
run: |
make build
for f in api mcp stripe-webhook webhook-worker jobs; do
aws s3 cp "dist/$f.zip" "s3://${{ vars.ARTIFACT_BUCKET }}/$RELEASE/$f.zip" --only-show-errors
done

- name: terraform init
if: steps.ready.outputs.ready == 'true'
working-directory: infra/terraform/prod
run: terraform init -input=false -backend-config="bucket=${{ vars.TF_STATE_BUCKET }}"

# Migrations run before new code ships. Safe because every migration
# is expand-only against the running release (README → Migrations).
- name: Migrate (existing cluster)
id: migrate
if: steps.ready.outputs.ready == 'true'
working-directory: infra/terraform/prod
run: |
ARN=$(terraform output -raw db_cluster_arn 2>/dev/null || true)
SECRET=$(terraform output -raw db_secret_arn 2>/dev/null || true)
if [ -n "$ARN" ] && [ -n "$SECRET" ]; then
cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate
echo "done=true" >> "$GITHUB_OUTPUT"
else
echo "done=false" >> "$GITHUB_OUTPUT" # first deploy: the cluster doesn't exist yet
fi

- name: terraform apply
if: steps.ready.outputs.ready == 'true'
working-directory: infra/terraform/prod
run: |
terraform apply -input=false -auto-approve \
-var "artifact_bucket=${{ vars.ARTIFACT_BUCKET }}" -var "release=$RELEASE" \
-var "domain_name=${{ vars.API_DOMAIN }}"

- name: Migrate (first deploy)
if: steps.ready.outputs.ready == 'true' && steps.migrate.outputs.done == 'false'
working-directory: infra/terraform/prod
run: |
ARN=$(terraform output -raw db_cluster_arn) SECRET=$(terraform output -raw db_secret_arn)
cd ../../.. && DATABASE_BACKEND=dataapi DB_CLUSTER_ARN="$ARN" DB_SECRET_ARN="$SECRET" go run ./cmd/migrate

- name: Smoke test
if: steps.ready.outputs.ready == 'true'
run: |
BASE="https://${{ vars.API_DOMAIN }}"
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/v1/health") && [ "$code" = 200 ] && break; sleep 5
done
[ "$code" = 200 ] || { echo "::error::health check failed ($code)"; exit 1; }
curl -sf "$BASE/.well-known/jwks.json" | grep -q '"keys"'
if [ -n "${{ secrets.SMOKE_API_KEY }}" ]; then
# A dedicated smoke-test User and Application seeded in production.
curl -sf "$BASE/v1/users/${{ vars.SMOKE_USER_ID }}/apps/${{ vars.SMOKE_APP_ID }}/effective-permissions" \
-H "Authorization: Bearer ${{ secrets.SMOKE_API_KEY }}" | grep -q '"allowed":true'
fi
43 changes: 43 additions & 0 deletions .github/workflows/rollback.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Emergency rollback (README → Rollback): move each function's live alias
# back to its previous published version, in seconds, without Terraform.
# Afterwards, land a revert of the bad merge on main so Terraform's view
# and reality match again.
name: Rollback

on:
workflow_dispatch:
inputs:
functions:
description: Functions to roll back
default: "api mcp stripe-webhook webhook-worker jobs"

permissions:
contents: read
id-token: write

concurrency:
group: production
cancel-in-progress: false

jobs:
rollback:
runs-on: ubuntu-latest
environment: production
steps:
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Point live at the previous version
run: |
for f in ${{ inputs.functions }}; do
name="substratal-prod-$f"
current=$(aws lambda get-alias --function-name "$name" --name live --query FunctionVersion --output text)
previous=$(aws lambda list-versions-by-function --function-name "$name" \
--query "Versions[?Version!='\$LATEST' && to_number(Version) < \`$current\`].Version | [-1]" --output text)
if [ -z "$previous" ] || [ "$previous" = "None" ]; then
echo "::warning::$name has no version before $current"; continue
fi
aws lambda update-alias --function-name "$name" --name live --function-version "$previous" >/dev/null
echo "$name: live $current → $previous"
done
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,12 @@ docs/Gemfile.lock

.DS_Store
docs/_data/versions.yml

# API
.env
.dev/
dist/
*.zip
infra/terraform/**/.terraform/
infra/terraform/**/*.tfstate*
infra/terraform/**/.terraform.lock.hcl.bak
Loading
Loading