Skip to content

Verify, build, and deploy production #34

Verify, build, and deploy production

Verify, build, and deploy production #34

Workflow file for this run

name: Verify, build, and deploy production
on:
pull_request:
branches: [main]
push:
branches: [main]
workflow_dispatch:
inputs:
verification_mode:
description: Public verification requires live Cloudflare DNS; use origin-only for the first pre-cutover deployment.
required: true
default: public
type: choice
options:
- public
- origin-only
schedule:
- cron: "0 22 * * 0"
concurrency:
group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'production' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
NODE_VERSION: "22.23.2"
EJAM_DATA_ROOT: ejam/data
NEXT_PUBLIC_APP_URL: https://www.deetnuts.com
jobs:
verify:
name: Static and unit verification
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Validate deployment configuration
run: |
bash -n deploy/bin/* deploy/bootstrap-host.sh
shellcheck deploy/bin/* deploy/bootstrap-host.sh
docker compose --env-file deploy/env/compose.env.example config --quiet
- name: Check formatting
run: npm run format:check:platform
- name: Lint
run: npm run lint
- name: Typecheck
run: npm run typecheck
- name: Test platform security helpers
run: npm run test:platform
- name: Test bot behavior
run: npm run test:bot
- name: Build worker
run: npm run bot:build
- name: Test admissions behavior
run: npm run test:admissions
- name: Test JEE cutoff behavior
run: npm run test:jee-cutoffs
- name: Audit dependencies
run: npm audit --audit-level=high
- name: Build application
env:
NEXT_PUBLIC_SOURCE_COMMIT: ${{ github.sha }}
NEXT_DEPLOYMENT_ID: ${{ github.sha }}
run: npm run build
browser-tests:
name: Browser verification
runs-on: ubuntu-24.04
permissions:
contents: read
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- name: Install dependencies
run: npm ci --no-audit --no-fund
- name: Install Chromium
run: npx playwright install --with-deps chromium
- name: Run browser tests
env:
NEXT_PUBLIC_SOURCE_COMMIT: ${{ github.sha }}
NEXT_DEPLOYMENT_ID: ${{ github.sha }}
run: npm run test:e2e:jee-cutoffs
images:
name: Build and secure images
if: github.event_name != 'pull_request'
needs: [verify, browser-tests]
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
id-token: write
attestations: write
outputs:
web-digest: ${{ steps.web.outputs.digest }}
worker-digest: ${{ steps.worker.outputs.digest }}
pocketbase-digest: ${{ steps.pocketbase.outputs.digest }}
env:
WEB_IMAGE: ghcr.io/${{ github.repository }}-web
WORKER_IMAGE: ghcr.io/${{ github.repository }}-worker
POCKETBASE_IMAGE: ghcr.io/${{ github.repository }}-pocketbase
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Validate persistent Server Actions key
env:
SERVER_ACTIONS_KEY: ${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
run: |
test -n "$SERVER_ACTIONS_KEY"
test "$(printf '%s' "$SERVER_ACTIONS_KEY" | base64 --decode | wc -c)" -eq 32
- name: Set up Buildx
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
- name: Authenticate to GHCR
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push web image
id: web
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.WEB_IMAGE }}:${{ github.sha }}
build-args: |
NEXT_PUBLIC_APP_URL=${{ env.NEXT_PUBLIC_APP_URL }}
NEXT_PUBLIC_SOURCE_COMMIT=${{ github.sha }}
NEXT_DEPLOYMENT_ID=${{ github.sha }}
OCI_CREATED=${{ github.event.repository.updated_at }}
OCI_REVISION=${{ github.sha }}
OCI_SOURCE=${{ github.server_url }}/${{ github.repository }}
secrets: |
next_server_actions_encryption_key=${{ secrets.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY }}
cache-from: type=gha,scope=web
cache-to: type=gha,mode=max,scope=web
no-cache-filters: builder
provenance: mode=max
sbom: true
- name: Build and push worker image
id: worker
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: .
file: Dockerfile.bot
platforms: linux/amd64
push: true
tags: ${{ env.WORKER_IMAGE }}:${{ github.sha }}
build-args: |
OCI_CREATED=${{ github.event.repository.updated_at }}
OCI_REVISION=${{ github.sha }}
OCI_SOURCE=${{ github.server_url }}/${{ github.repository }}
cache-from: type=gha,scope=worker
cache-to: type=gha,mode=max,scope=worker
provenance: mode=max
sbom: true
- name: Build and push PocketBase image
id: pocketbase
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
with:
context: pocketbase
file: pocketbase/Dockerfile
platforms: linux/amd64
push: true
tags: ${{ env.POCKETBASE_IMAGE }}:${{ github.sha }}
cache-from: type=gha,scope=pocketbase
cache-to: type=gha,mode=max,scope=pocketbase
provenance: mode=max
sbom: true
- name: Attest web image
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.WEB_IMAGE }}
subject-digest: ${{ steps.web.outputs.digest }}
push-to-registry: true
- name: Attest worker image
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.WORKER_IMAGE }}
subject-digest: ${{ steps.worker.outputs.digest }}
push-to-registry: true
- name: Attest PocketBase image
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
with:
subject-name: ${{ env.POCKETBASE_IMAGE }}
subject-digest: ${{ steps.pocketbase.outputs.digest }}
push-to-registry: true
- name: Pull immutable images for inspection
run: |
docker pull "${WEB_IMAGE}@${{ steps.web.outputs.digest }}"
docker pull "${WORKER_IMAGE}@${{ steps.worker.outputs.digest }}"
docker pull "${POCKETBASE_IMAGE}@${{ steps.pocketbase.outputs.digest }}"
- name: Enforce image sizes
run: |
compressed_size() {
local image=$1
local index_digest=$2
local manifest_digest
manifest_digest=$(
docker buildx imagetools inspect --raw "${image}@${index_digest}" |
jq -er '
[.manifests[]
| select(.platform.os == "linux" and .platform.architecture == "amd64")
| .digest]
| if length == 1 then .[0] else error("expected exactly one linux/amd64 manifest") end
'
)
docker buildx imagetools inspect --raw "${image}@${manifest_digest}" |
jq -er '[.layers[].size] | add'
}
web_size=$(compressed_size "$WEB_IMAGE" "${{ steps.web.outputs.digest }}")
worker_size=$(compressed_size "$WORKER_IMAGE" "${{ steps.worker.outputs.digest }}")
pocketbase_size=$(compressed_size "$POCKETBASE_IMAGE" "${{ steps.pocketbase.outputs.digest }}")
printf 'Compressed image sizes (bytes): web=%s worker=%s pocketbase=%s\n' "$web_size" "$worker_size" "$pocketbase_size"
test "$web_size" -le 402653184
test "$worker_size" -le 134217728
test "$pocketbase_size" -le 67108864
- name: Scan web image
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
version: v0.74.0
image-ref: ${{ env.WEB_IMAGE }}@${{ steps.web.outputs.digest }}
format: table
exit-code: "1"
ignore-unfixed: true
severity: HIGH,CRITICAL
- name: Scan worker image
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
version: v0.74.0
image-ref: ${{ env.WORKER_IMAGE }}@${{ steps.worker.outputs.digest }}
format: table
exit-code: "1"
ignore-unfixed: true
severity: HIGH,CRITICAL
- name: Scan PocketBase image
uses: aquasecurity/trivy-action@a9c7b0f06e461e9d4b4d1711f154ee024b8d7ab8 # v0.36.0
with:
version: v0.74.0
image-ref: ${{ env.POCKETBASE_IMAGE }}@${{ steps.pocketbase.outputs.digest }}
format: table
exit-code: "1"
ignore-unfixed: true
severity: HIGH,CRITICAL
- name: Verify registry attestations
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh attestation verify "oci://${WEB_IMAGE}@${{ steps.web.outputs.digest }}" --repo "$GITHUB_REPOSITORY"
gh attestation verify "oci://${WORKER_IMAGE}@${{ steps.worker.outputs.digest }}" --repo "$GITHUB_REPOSITORY"
gh attestation verify "oci://${POCKETBASE_IMAGE}@${{ steps.pocketbase.outputs.digest }}" --repo "$GITHUB_REPOSITORY"
deploy:
name: Deploy verified digests
if: >-
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
vars.DO_DEPLOY_ENABLED == 'true'
needs: images
runs-on: ubuntu-24.04
timeout-minutes: 30
environment: production
permissions:
contents: read
packages: read
env:
WEB_IMAGE: ghcr.io/${{ github.repository }}-web
WORKER_IMAGE: ghcr.io/${{ github.repository }}-worker
POCKETBASE_IMAGE: ghcr.io/${{ github.repository }}-pocketbase
DO_HOST: ${{ secrets.DO_HOST }}
DO_FIREWALL_ID: ${{ secrets.DO_FIREWALL_ID }}
DO_API_TOKEN: ${{ secrets.DO_API_TOKEN }}
DEPLOY_USER: ${{ vars.DO_DEPLOY_USER || 'deetnuts-deploy' }}
VERIFICATION_MODE: ${{ github.event_name == 'workflow_dispatch' && inputs.verification_mode || 'public' }}
steps:
- name: Validate deployment configuration
env:
SSH_PRIVATE_KEY: ${{ secrets.DO_SSH_PRIVATE_KEY }}
SSH_HOST_KEY: ${{ secrets.DO_SSH_HOST_KEY }}
run: |
[[ "$DO_HOST" =~ ^[A-Za-z0-9.-]+$ ]]
[[ "$DO_FIREWALL_ID" =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]]
[[ ${#DO_API_TOKEN} -ge 32 ]]
[[ "$DEPLOY_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]
grep -qx -- '-----BEGIN OPENSSH PRIVATE KEY-----' <<<"$SSH_PRIVATE_KEY"
grep -qx -- '-----END OPENSSH PRIVATE KEY-----' <<<"$SSH_PRIVATE_KEY"
grep -Eq '(^|[[:space:]])ssh-ed25519[[:space:]][A-Za-z0-9+/=]+$' <<<"$SSH_HOST_KEY"
- name: Resolve runner address
id: runner
run: |
address=$(curl --fail --silent --show-error https://api.ipify.org)
[[ "$address" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]
echo "cidr=${address}/32" >> "$GITHUB_OUTPUT"
- name: Temporarily permit deployment SSH
env:
RUNNER_CIDR: ${{ steps.runner.outputs.cidr }}
run: |
jq -n --arg cidr "$RUNNER_CIDR" '{inbound_rules:[{protocol:"tcp",ports:"22",sources:{addresses:[$cidr]}}]}' > /tmp/firewall-rule.json
curl --fail --silent --show-error \
--connect-timeout 10 \
--max-time 30 \
--request POST \
--header "Authorization: Bearer $DO_API_TOKEN" \
--header "Content-Type: application/json" \
--data-binary @/tmp/firewall-rule.json \
"https://api.digitalocean.com/v2/firewalls/$DO_FIREWALL_ID/rules"
- name: Configure pinned SSH identity
env:
SSH_PRIVATE_KEY: ${{ secrets.DO_SSH_PRIVATE_KEY }}
SSH_HOST_KEY: ${{ secrets.DO_SSH_HOST_KEY }}
run: |
install -d -m 0700 "$HOME/.ssh"
printf '%s\n' "$SSH_PRIVATE_KEY" > "$HOME/.ssh/deetnuts"
chmod 0600 "$HOME/.ssh/deetnuts"
printf '%s\n' "$SSH_HOST_KEY" > "$HOME/.ssh/known_hosts"
chmod 0600 "$HOME/.ssh/known_hosts"
- name: Deploy release
run: |
ssh \
-i "$HOME/.ssh/deetnuts" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
-o StrictHostKeyChecking=yes \
-o ConnectTimeout=15 \
"$DEPLOY_USER@$DO_HOST" \
"deploy $GITHUB_SHA ${WEB_IMAGE}@${{ needs.images.outputs.web-digest }} ${WORKER_IMAGE}@${{ needs.images.outputs.worker-digest }} ${POCKETBASE_IMAGE}@${{ needs.images.outputs.pocketbase-digest }} $VERIFICATION_MODE"
- name: Remove temporary deployment SSH rule
if: always() && steps.runner.outputs.cidr != ''
env:
RUNNER_CIDR: ${{ steps.runner.outputs.cidr }}
run: |
jq -n --arg cidr "$RUNNER_CIDR" '{inbound_rules:[{protocol:"tcp",ports:"22",sources:{addresses:[$cidr]}}]}' > /tmp/firewall-rule.json
curl --fail --silent --show-error \
--connect-timeout 10 \
--max-time 30 \
--retry 5 \
--retry-all-errors \
--retry-delay 2 \
--request DELETE \
--header "Authorization: Bearer $DO_API_TOKEN" \
--header "Content-Type: application/json" \
--data-binary @/tmp/firewall-rule.json \
"https://api.digitalocean.com/v2/firewalls/$DO_FIREWALL_ID/rules"