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
36 changes: 36 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
**/node_modules
**/dist
**/build
**/.git
**/.github
**/.vscode
**/.idea
**/coverage
**/.turbo
**/.tsbuildinfo
**/*.log
**/.DS_Store

# Local env + dev runtime state — must never enter the build context.
.env
.env.*
!.env.example
*.local
*.local.*
private-storage/
codeforphilly-data/
apps/codeforphilly-data/

# Agent worktrees + plans/specs (built artifacts don't need them).
.claude/worktrees/

# Test artifacts
**/tests
**/*.test.ts
**/vitest.config.ts

# Docs / specs not needed at runtime.
docs/
plans/
specs/
README.md
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -70,3 +70,12 @@ CFP_JWT_SIGNING_KEY=change-me-to-a-random-string-at-least-32-chars

# PEM-encoded certificate matching SAML_PRIVATE_KEY.
# SAML_CERTIFICATE=-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----

# ---------------------------------------------------------------------------
# Static SPA serving (production only)
# ---------------------------------------------------------------------------

# Absolute path to the built apps/web/dist directory. When set, the API
# serves the SPA as a fallthrough for non-/api/* routes (single-image
# deploy per specs/architecture.md). Leave unset in dev — Vite owns 5173.
# CFP_WEB_DIST_PATH=/app/apps/web/dist
131 changes: 131 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
name: Deploy (production)

# Production deploys run only on annotated/lightweight tags shaped like
# `v1.2.3`. Same image, different cluster, different values.
on:
push:
tags:
- "v*.*.*"
workflow_dispatch:
inputs:
tag:
description: "Image tag to deploy (must already exist in GHCR)"
required: true

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

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

permissions:
contents: read
packages: write
id-token: write

jobs:
build:
if: github.event_name == 'push'
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.image-tag }}
steps:
- uses: actions/checkout@v6

- name: Compute image tag
id: meta
run: |
tag="${GITHUB_REF_NAME}"
echo "image-tag=$tag" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64
provenance: false
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.image-tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:production-latest
cache-from: type=gha
cache-to: type=gha,mode=max
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}

deploy:
# When triggered by a tag push, depend on build; for workflow_dispatch the
# tag must already exist in GHCR, so we skip the build job.
needs: [build]
if: always() && (needs.build.result == 'success' || github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest
environment:
name: production
url: https://codeforphilly.org
steps:
- uses: actions/checkout@v6

- name: Install kubectl
uses: azure/setup-kubectl@v4
with:
version: v1.31.0

- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.16.2

- name: Configure kubeconfig
run: |
mkdir -p "$HOME/.kube"
echo "${{ secrets.KUBECONFIG_PRODUCTION }}" | base64 -d > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"
kubectl version --client

- name: Resolve image tag
id: tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "image-tag=${{ github.event.inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "image-tag=${{ needs.build.outputs.image-tag }}" >> "$GITHUB_OUTPUT"
fi

- name: Helm upgrade
run: |
helm upgrade --install codeforphilly \
deploy/charts/codeforphilly \
--namespace codeforphilly \
--create-namespace \
-f deploy/charts/codeforphilly/values.production.yaml \
--set image.tag=${{ steps.tag.outputs.image-tag }} \
--atomic \
--timeout 5m \
--wait

- name: Smoke check
run: |
for i in 1 2 3 4 5 6; do
if curl -fsS https://codeforphilly.org/api/health >/dev/null; then
echo "OK"
exit 0
fi
echo "Try $i: not ready, sleeping 10s"
sleep 10
done
echo "Production health check failed"
exit 1
125 changes: 125 additions & 0 deletions .github/workflows/deploy-staging.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
name: Deploy (staging)

on:
push:
branches: [main]
workflow_dispatch:

concurrency:
# Cancel an in-flight deploy if a newer commit lands — only the latest gets
# rolled out to staging.
group: deploy-staging
cancel-in-progress: false

env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}

permissions:
contents: read
packages: write
id-token: write

jobs:
# Build + push the image. Tag with both the commit sha and `staging-latest`.
build:
runs-on: ubuntu-latest
outputs:
image-tag: ${{ steps.meta.outputs.image-tag }}
image-digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@v6

- name: Compute image tag
id: meta
run: |
tag="sha-${GITHUB_SHA::12}"
echo "image-tag=$tag" >> "$GITHUB_OUTPUT"

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
id: push
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile
push: true
platforms: linux/amd64
provenance: false
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.meta.outputs.image-tag }}
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:staging-latest
cache-from: type=gha
cache-to: type=gha,mode=max
labels: |
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}

# Deploy via `helm upgrade --install` against the staging cluster. Gated by
# the `staging` environment so first-time runs require an approval and so
# secrets are scoped per-environment.
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: https://codeforphilly-rewrite-staging.k8s.phl.io
steps:
- uses: actions/checkout@v6

- name: Install kubectl
uses: azure/setup-kubectl@v4
with:
version: v1.31.0

- name: Install Helm
uses: azure/setup-helm@v4
with:
version: v3.16.2

- name: Configure kubeconfig
# KUBECONFIG_STAGING is a base64-encoded kubeconfig stored as a repo
# secret. The cluster service account it points to should have rights
# only in the codeforphilly-staging namespace.
run: |
mkdir -p "$HOME/.kube"
echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 -d > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"
kubectl version --client

- name: Helm upgrade
run: |
helm upgrade --install codeforphilly-staging \
deploy/charts/codeforphilly \
--namespace codeforphilly-staging \
--create-namespace \
-f deploy/charts/codeforphilly/values.staging.yaml \
--set image.tag=${{ needs.build.outputs.image-tag }} \
--atomic \
--timeout 5m \
--wait

- name: Smoke check
run: |
# The --wait above only waits for k8s to report ready; hit the
# public ingress to confirm end-to-end. Retries because cert-manager
# may still be re-checking TLS for a fresh cert.
for i in 1 2 3 4 5 6; do
if curl -fsS https://codeforphilly-rewrite-staging.k8s.phl.io/api/health >/dev/null; then
echo "OK"
exit 0
fi
echo "Try $i: not ready, sleeping 10s"
sleep 10
done
echo "Staging health check failed"
exit 1
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
nodejs 22.22.3
helm 4.1.0
Loading