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
28 changes: 28 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Downloaded issuer PDFs and the corpus stay on the machine that built them
# (ADR 0016). Nothing in the image needs them, and the image must never carry
# them.
data/

.venv/
logs/
frontend/
tests/
scripts/
docs/
.playwright-mcp/
.git/
.github/
.claude/
.ruff_cache/
.pytest_cache/
.coverage
**/__pycache__/
**/*.py[cod]
.env
.env.*
*.md
# pyproject.toml declares it as the package readme, so hatchling needs it to
# build the project itself.
!README.md
Makefile
docker-compose.yml
23 changes: 23 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Copy to .env and fill in. Never commit .env.
# Every setting and its default is in src/policypal/config.py; only the three
# with no default are required.

# --- Required ---
DATABASE_URL=postgresql+psycopg://policypal:password@localhost:5432/policypal
OPENAI_API_KEY=
# A long random string, e.g. `python -c "import secrets; print(secrets.token_urlsafe(48))"`
JWT_SECRET_KEY=

# --- Optional ---
# Live plan prices come from the CMS Marketplace API. Without a key, plan
# search still works from the stored catalog; the prices are the stored
# age-27 ones rather than the user's age.
CMS_MARKETPLACE_API_KEY=

# "production" switches logs to JSON on stdout as well as to a file.
ENVIRONMENT=development
LOG_LEVEL=INFO
LOG_DIR=logs/backend

# Where the browser app is served from. CORS allows this origin and no other.
FRONTEND_ORIGIN=http://localhost:5173
171 changes: 171 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
name: CD

# Only after CI has passed on main. `workflow_run` is what makes "green CI"
# a precondition rather than a hope: a push that fails CI never reaches here.
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch:
inputs:
sha:
description: The commit to deploy. Defaults to the head of main.
required: false

# Federated credentials, so no Azure secret is stored in GitHub (ADR 0023).
permissions:
contents: read
id-token: write

env:
IMAGE_NAME: policypal-api

jobs:
build:
# A failed CI run must not deploy. workflow_dispatch has no parent run.
if: >-
github.event_name == 'workflow_dispatch' ||
github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
sha: ${{ steps.commit.outputs.sha }}
steps:
# `inputs.sha` is typed by a human and ends up in `ref:` and in an image
# tag, so it is checked to be a full commit id and nothing else.
- id: commit
env:
DISPATCH_SHA: ${{ inputs.sha }}
RUN_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
set -euo pipefail
sha="${DISPATCH_SHA:-${RUN_SHA:-$GITHUB_SHA}}"
if ! printf '%s' "$sha" | grep -Eq '^[0-9a-f]{40}$'; then
echo "not a commit id: refusing to deploy"; exit 1
fi
echo "sha=$sha" >> "$GITHUB_OUTPUT"

- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.commit.outputs.sha }}

- uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

- name: Sign in to the registry
env:
ACR_NAME: ${{ vars.ACR_NAME }}
run: az acr login --name "$ACR_NAME"

- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1

# Tagged with the commit, and deployed by digest: a tag can be moved,
# a digest cannot, so what is approved is exactly what runs.
- id: push
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
push: true
tags: ${{ vars.ACR_NAME }}.azurecr.io/policypal-api:${{ steps.commit.outputs.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
needs: build
runs-on: ubuntu-latest
# Production is a GitHub Environment with a required reviewer: this job
# waits for a human before anything reaches Azure.
environment:
name: production
url: ${{ vars.API_URL }}
steps:
- uses: azure/login@a65d910e8af852a8061c627c456678983e180302 # v2.2.0
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

# Migrations run inside Azure, as a Container Apps job on the image being
# deployed, so the database needs no public firewall opening for a
# GitHub runner and the schema is migrated by exactly the code that will
# then serve it.
- name: Migrate the database
env:
RG: ${{ vars.AZURE_RESOURCE_GROUP }}
JOB: ${{ vars.MIGRATION_JOB_NAME }}
IMAGE: ${{ vars.ACR_NAME }}.azurecr.io/policypal-api@${{ needs.build.outputs.digest }}
run: |
set -euo pipefail
az containerapp job update --name "$JOB" --resource-group "$RG" --image "$IMAGE"
execution=$(az containerapp job start --name "$JOB" --resource-group "$RG" \
--query name -o tsv)
echo "Waiting on $execution"
for _ in $(seq 1 60); do
status=$(az containerapp job execution show --name "$JOB" --resource-group "$RG" \
--job-execution-name "$execution" --query properties.status -o tsv)
case "$status" in
Succeeded) echo "migrations applied"; exit 0 ;;
Failed|Degraded) echo "migration job $status"; exit 1 ;;
esac
sleep 10
done
echo "migration job did not finish in 10 minutes"; exit 1

- name: Deploy the API
env:
RG: ${{ vars.AZURE_RESOURCE_GROUP }}
APP: ${{ vars.CONTAINERAPP_NAME }}
IMAGE: ${{ vars.ACR_NAME }}.azurecr.io/policypal-api@${{ needs.build.outputs.digest }}
run: |
set -euo pipefail
az containerapp update --name "$APP" --resource-group "$RG" --image "$IMAGE"
az containerapp ingress show --name "$APP" --resource-group "$RG" -o none

- name: Check it answers
env:
API_URL: ${{ vars.API_URL }}
run: |
set -euo pipefail
for _ in $(seq 1 30); do
code=$(curl -s -o /dev/null -w '%{http_code}' "$API_URL/api/counties?zip=33101" || true)
if [ "$code" = "200" ]; then echo "API answering"; exit 0; fi
sleep 10
done
echo "API did not answer 200 within five minutes"; exit 1

frontend:
needs: [build, deploy]
runs-on: ubuntu-latest
environment:
name: production
url: ${{ vars.FRONTEND_URL }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ needs.build.outputs.sha }}

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "24"
cache: npm
cache-dependency-path: frontend/package-lock.json

- name: Build the browser app
working-directory: frontend
env:
# Baked in at build time: Vite has no runtime configuration.
VITE_API_BASE_URL: ${{ vars.API_URL }}
run: |
npm ci
npm run build

- uses: Azure/static-web-apps-deploy@1a947af9992250f3bc2e68ad0754c0b0c11566c9 # v1.0.0
with:
azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }}
action: upload
app_location: frontend/dist
skip_app_build: true
50 changes: 49 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
name: CI

on:
# Every pull request, not only those targeting main: a stacked PR based on
# another branch needs the same gate as any other, and the branch filter
# silently gave it none.
pull_request:
branches: [main]
push:
branches: [main]

Expand Down Expand Up @@ -83,6 +85,52 @@ jobs:
- name: Dependency audit
run: npm audit --audit-level=high

# Closes the first of ADR 0002's two deferred items. The image is built and
# scanned on every PR; pushing it anywhere is the deploy workflow's job.
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1

- name: Build the API image
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
with:
context: .
load: true
tags: policypal-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

# The PDFs and the corpus are local-only (ADR 0016) and .dockerignore
# excludes data/. A regression there would ship gigabytes of issuer
# documents, so it is checked rather than trusted.
- name: The image must carry no issuer documents
env:
IMAGE: policypal-api:${{ github.sha }}
run: |
set -euo pipefail
found=$(docker run --rm --entrypoint sh "$IMAGE" -c 'ls -A /app/data 2>/dev/null | wc -l')
test "$found" -eq 0 || { echo "data/ reached the image"; exit 1; }

# Trivy's own action installs its binary through a script that fails
# silently on the runner; its published image is pinned instead, the
# same way gitleaks is. `--input` on a saved tar means no docker socket
# has to be handed to the scanner.
- name: Scan the image
env:
IMAGE: policypal-api:${{ github.sha }}
# Pinned by digest: a tag can be moved to different bytes.
TRIVY: "aquasec/trivy:0.64.1@sha256:a8ca29078522f30393bdb34225e4c0994d38f37083be81a42da3a2a7e1488e9e"
run: |
set -euo pipefail
docker save "$IMAGE" -o "$RUNNER_TEMP/image.tar"
docker run --rm -v "$RUNNER_TEMP:/scan" "$TRIVY" image \
--input /scan/image.tar \
--format table --exit-code 1 --ignore-unfixed --severity HIGH,CRITICAL
rm -f "$RUNNER_TEMP/image.tar"

secret-scan:
runs-on: ubuntu-latest
env:
Expand Down
Loading
Loading