Verify, build, and deploy production #34
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" |