Skip to content

Best practices (sample): a sample project in every workspace #442

Best practices (sample): a sample project in every workspace

Best practices (sample): a sample project in every workspace #442

Workflow file for this run

name: platform
# Checks, images and deploys for dembrane/platform (the Bun rewrite) and the frontend it
# serves. Checks and image builds run in parallel; images are built once and pushed, and a
# deploy only migrates, rolls out and verifies.
#
# A run checks only the side it changed: 00-plan diffs the commit against its base (a PR's base
# branch, or the previous commit of a push) and skips the server checks when only
# dembrane/frontend changed, and the frontend checks the other way round. Each image is tagged
# with a hash of its inputs, and an image whose inputs are already in the registry is tagged
# for the new commit instead of built again (.github/scripts/build-images.sh). A PR preview rolls out
# only the services whose image or settings changed (.github/scripts/deploy-env.sh).
#
# PR previews and staging are each their own GCP project, reached through its own keyless GitHub
# trust, and push images to their own registry: a preview identity has no role in staging.
#
# PR previews (dembrane-web-previews): adding the "preview" label deploys one (echo-pr-<n>-*,
# database echo_pr_<n>, dashboard-<n>, portal-<n> and api-<n>.preview.dembrane.com), each push
# to a labelled PR redeploys it, and removing the label, closing or merging removes it. Every
# deploy seeds the preview admin, the fictional sample project and the accounts demo. At most three
# exist; a fourth tears down the oldest. One comment on the PR carries the links, and one
# message in #alerts-ci shows the preview's current state, with its history in the thread.
#
# staging (dembrane-web-staging) deploys main: by hand, and on every push to main once the
# repository variable STAGING_DEPLOY_ON_MAIN is "true". Each deploy lists the PRs it carried in
# #alerts-ci, under the dashboard's link, and comments on each PR.
#
# echo-next (the dembrane-web-dummy app on the DigitalOcean dev cluster) runs main through the
# path prod releases take: every push to main, and a run by hand with target echo-next, pushes
# the images to registry.digitalocean.com/dbr-cr as dembrane-web-<app> and sets global.imageTag
# in helm/dembrane-web/values-echo-next.yaml of Dembrane/echo-gitops. Argo CD syncs that app by
# itself, and the job waits for api.echo-next.dembrane.com to serve the commit. No tag and no
# approval. Only that app reads values-echo-next.yaml, so this never moves prod.
#
# prod runs on DigitalOcean Kubernetes through GitOps. A release tag vX.Y.Z on main, after the
# prod environment's approval (by hand with the tag, and on pushing the tag once
# PROD_DEPLOY_ON_TAG is "true"), pushes the images the same way and sets global.imageTag in
# helm/dembrane-web/values-prod.yaml of Dembrane/echo-gitops; Argo CD migrates, rolls out and
# smoke-tests from there. The branch both commit to is GITOPS_PROD_BRANCH (prod-v3 until the
# cutover in echo-gitops' CUTOVER.md, then main). Once PROD_WAIT_FOR_ROLLOUT is "true" the prod
# job waits for api.dembrane.com to serve the release. Then it publishes the GitHub Release,
# tells #team-engineering, #alerts-ci and each PR, and sends
# release.published to sam, which drafts the in-app release notes for review.
#
# #alerts-ci says when an environment is being created or updated, in a message that is edited
# when the deploy ends: created, updated, removed, or not updated and why. Checks on PRs
# without a preview stay in the PR.
#
# Caches: pull request runs only restore them. Pushes to main (and runs by hand) check trusted
# code and save them, so code from a PR never writes a cache that another run reads. Images
# have no layer cache: 40-build-images builds each changed image once and pushes that build. A PR can
# only read caches saved on its base branch, so until the cutover, when main starts running
# this workflow, PR runs start cold. feat/bun-migration has no push run: its PR checks every
# commit, and a second run would only duplicate every check.
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled, closed]
paths:
- "dembrane/platform/**"
- "dembrane/frontend/**"
- "dembrane/infra/**"
- ".github/workflows/platform.yml"
- ".github/scripts/**"
push:
branches: [main]
# Tag pushes ignore the path filter.
tags: ["v[0-9]+.[0-9]+.[0-9]+"]
paths:
- "dembrane/platform/**"
- "dembrane/frontend/**"
- "dembrane/infra/**"
- ".github/workflows/platform.yml"
- ".github/scripts/**"
workflow_dispatch:
inputs:
target:
description: What to deploy (PR previews come only from the "preview" label on a PR)
type: choice
options: [staging, echo-next, prod]
default: staging
tag:
description: "prod: the release tag to deploy (vX.Y.Z, on main)"
type: string
default: ""
gitops_branch:
description: "prod: the Dembrane/echo-gitops branch to bump (empty: GITOPS_PROD_BRANCH, else prod-v3)"
type: string
default: ""
hold_data:
description: "staging: deploy the migrate job without running it and keep the workers at 0"
type: boolean
default: true
permissions:
contents: read
concurrency:
group: platform-${{ github.event.pull_request.number || inputs.target || github.ref }}
# A newer push replaces a running check or preview; a staging, echo-next or prod rollout runs
# to the end.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
defaults:
run:
working-directory: dembrane/platform
env:
REGION: europe-west4
REGISTRY: europe-west4-docker.pkg.dev/dembrane-web-previews/echo-preview
# Pull request runs build the merge commit GitHub prepares (github.sha), the code the checks
# test; a prod run builds its release tag.
REF: ${{ inputs.tag || github.sha }}
jobs:
# Decides what this run does: checks only, a PR preview deploy or teardown, or a staging,
# echo-next or prod deploy. A push to main deploys echo-next beside staging.
plan:
name: 00-plan
runs-on: ubuntu-latest
timeout-minutes: 3
defaults:
run:
working-directory: .
outputs:
sha: ${{ steps.plan.outputs.sha }}
pr: ${{ steps.plan.outputs.pr }}
deploy_pr: ${{ steps.plan.outputs.deploy_pr }}
deploy_env: ${{ steps.plan.outputs.deploy_env }}
deploy_next: ${{ steps.plan.outputs.deploy_next }}
release_tag: ${{ steps.plan.outputs.release_tag }}
teardown: ${{ steps.plan.outputs.teardown }}
server: ${{ steps.changes.outputs.server }}
frontend: ${{ steps.changes.outputs.frontend }}
images: ${{ steps.changes.outputs.images }}
steps:
- name: Plan the run
id: plan
env:
GH_TOKEN: ${{ github.token }}
EVENT: ${{ github.event_name }}
ACTION: ${{ github.event.action }}
PR_EVENT: ${{ github.event.pull_request.number }}
TARGET: ${{ inputs.target }}
TAG_INPUT: ${{ inputs.tag }}
HAS_LABEL: ${{ contains(github.event.pull_request.labels.*.name, 'preview') }}
EVENT_LABEL: ${{ github.event.label.name }}
STAGING_ON_MAIN: ${{ vars.STAGING_DEPLOY_ON_MAIN }}
PROD_ON_TAG: ${{ vars.PROD_DEPLOY_ON_TAG }}
run: |
sha=$GITHUB_SHA pr='' deploy_pr=false deploy_env='' deploy_next=false release_tag='' teardown=false
case "$EVENT" in
workflow_dispatch)
# The GCP trusts enforce the same refs; failing here says why before any build.
[ "$GITHUB_REF" = refs/heads/main ] || { echo "::error::$TARGET deploys run from main, not $GITHUB_REF"; exit 1; }
if [ "$TARGET" = echo-next ]; then deploy_next=true; else deploy_env=$TARGET; fi
if [ "$TARGET" = prod ]; then release_tag=$TAG_INPUT
elif [ -n "$TAG_INPUT" ]; then echo "::error::A tag is for prod; $TARGET deploys main"; exit 1; fi
;;
push)
case "$GITHUB_REF" in
refs/tags/*)
if [ "$PROD_ON_TAG" = true ]; then deploy_env=prod release_tag=$GITHUB_REF_NAME
else echo "::notice::Tag pushes deploy prod once the variable PROD_DEPLOY_ON_TAG is true; checks only"; fi ;;
refs/heads/main)
deploy_next=true
if [ "$STAGING_ON_MAIN" = true ]; then deploy_env=staging
else echo "::notice::Pushes to main deploy staging once the variable STAGING_DEPLOY_ON_MAIN is true; checks only"; fi ;;
esac
;;
pull_request)
pr=$PR_EVENT
if [ "$ACTION" = closed ]; then teardown=true
elif [ "$ACTION" = unlabeled ]; then
if [ "$EVENT_LABEL" = preview ]; then teardown=true; fi
# Adding some other label to a preview PR is not a reason to redeploy it.
elif [ "$ACTION" = labeled ] && [ "$EVENT_LABEL" != preview ]; then :
elif [ "$HAS_LABEL" = true ]; then deploy_pr=true; fi
;;
esac
if [ "$deploy_env" = prod ]; then
[[ $release_tag =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "::error::prod deploys a release tag vX.Y.Z, not '$release_tag'"; exit 1; }
sha=$(gh api "repos/$GITHUB_REPOSITORY/commits/$release_tag" --jq .sha) || { echo "::error::No tag $release_tag"; exit 1; }
case "$(gh api "repos/$GITHUB_REPOSITORY/compare/main...$sha" --jq .status)" in
identical | behind) ;;
*) echo "::error::$release_tag ($sha) is not on main"; exit 1 ;;
esac
fi
echo "sha=$sha pr=$pr deploy_pr=$deploy_pr deploy_env=$deploy_env deploy_next=$deploy_next release_tag=$release_tag teardown=$teardown"
{
echo "sha=$sha"; echo "pr=$pr"; echo "deploy_pr=$deploy_pr"; echo "deploy_env=$deploy_env"
echo "deploy_next=$deploy_next"
echo "release_tag=$release_tag"; echo "teardown=$teardown"
} >> "$GITHUB_OUTPUT"
# A pull request's merge commit has the base branch tip as its first parent, so two
# commits of history are enough to diff what the PR changes. Trees only, no file contents.
- name: Check out history for the diff
if: steps.plan.outputs.teardown != 'true'
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
ref: ${{ env.REF }}
persist-credentials: false
fetch-depth: 2
filter: blob:none
sparse-checkout: .github/scripts
# Which sides this run checks and which images it builds. Runs by hand, tags and anything
# the diff cannot be computed for check and build everything.
- name: Find what changed
id: changes
if: steps.plan.outputs.teardown != 'true'
env:
EVENT: ${{ github.event_name }}
BEFORE: ${{ github.event.before }}
run: |
all() { echo "server=true"; echo "frontend=true"; echo "images=api worker migrate media web"; }
base=''
case "$EVENT/$GITHUB_REF" in
pull_request/*) base=$(git rev-parse HEAD^1 2>/dev/null) || base='' ;;
push/refs/heads/*)
if [[ $BEFORE =~ ^[0-9a-f]{40}$ && $BEFORE != 0000000000000000000000000000000000000000 ]] &&
git fetch -q --depth=1 --filter=blob:none origin "$BEFORE" 2>/dev/null; then base=$BEFORE; fi ;;
esac
if [ -z "$base" ]; then
echo "Checking and building everything (no base to diff against)"
all >> "$GITHUB_OUTPUT"
exit 0
fi
files=$(git diff --no-renames --name-only "$base" HEAD)
if grep -qE '^\.github/(workflows/platform\.yml$|scripts/)' <<<"$files"; then
echo "The workflow or its scripts changed: checking and building everything"
all >> "$GITHUB_OUTPUT"
exit 0
fi
server=false frontend=false
grep -qE '^dembrane/(platform|infra)/' <<<"$files" && server=true
grep -q '^dembrane/frontend/' <<<"$files" && frontend=true
images=$(.github/scripts/build-images.sh changed "$base" HEAD)
echo "server=$server frontend=$frontend images=[$images] (against ${base:0:7})"
{ echo "server=$server"; echo "frontend=$frontend"; echo "images=$images"; } >> "$GITHUB_OUTPUT"
check-server:
name: 10-check-server
needs: plan
if: needs.plan.outputs.teardown != 'true' && needs.plan.outputs.server == 'true'
runs-on: ubuntu-latest
timeout-minutes: 8
services:
postgres:
image: pgvector/pgvector:0.8.1-pg16
env: { POSTGRES_USER: echo, POSTGRES_PASSWORD: echo, POSTGRES_DB: postgres }
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U echo -d postgres" --health-interval 1s --health-retries 60
env:
TEST_DATABASE_ADMIN_URL: postgres://echo:echo@localhost:5432/postgres
SAVE_CACHE: ${{ github.event_name != 'pull_request' }}
steps:
- name: Check out code
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: { ref: "${{ env.REF }}", persist-credentials: false }
- name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: dembrane/platform/.bun-version
no-cache: ${{ github.event_name == 'pull_request' }}
- name: Restore Bun cache
id: bun-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.bun/install/cache
key: bun-${{ hashFiles('dembrane/platform/bun.lock') }}
restore-keys: bun-
# The incremental type-check state: a run after a small change re-checks only it.
- name: Restore type-check cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: dembrane/platform/.cache
key: tsc-${{ github.sha }}
restore-keys: tsc-
# The pipeline and media tests run real ffmpeg: the same static build the media image
# ships, copied out of its image once and cached.
- name: Restore ffmpeg cache
id: ffmpeg
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/ffmpeg
key: ffmpeg-static-7.1.1
- name: Download ffmpeg
if: steps.ffmpeg.outputs.cache-hit != 'true'
run: |
mkdir -p ~/ffmpeg
id=$(docker create mwader/static-ffmpeg:7.1.1)
docker cp "$id:/ffmpeg" ~/ffmpeg/ffmpeg && docker cp "$id:/ffprobe" ~/ffmpeg/ffprobe
docker rm "$id" >/dev/null
# Saved before any repository code runs, so the cache holds only the upstream image's files.
- name: Save ffmpeg cache
if: env.SAVE_CACHE == 'true' && steps.ffmpeg.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/ffmpeg
key: ffmpeg-static-7.1.1
- name: Add ffmpeg to PATH
run: echo ~/ffmpeg >> "$GITHUB_PATH"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Lint and format
run: bunx biome ci .
- name: Type check
run: bun run typecheck
# Config is declared, valid in every environment, and read.
- name: Check config
run: bun run config check
# Packages import down the layers, and README.md's package map is current.
- name: Check package layers
run: bun run packages check
- name: Check schema matches migrations
working-directory: dembrane/platform/packages/db
run: bunx drizzle-kit generate < /dev/null | tee /dev/stderr | grep -q "No schema changes"
# The frontend keeps generated copies of the accounts contract and the error catalog. The
# scripts need only Node, so a server-only change is held to them without the frontend job.
- name: Check the frontend's API types are in sync
working-directory: dembrane/frontend
run: |
node scripts/sync-accounts-contract.mjs --check
node scripts/sync-error-codes.mjs --check
# Files in parallel, each database test on its own database.
- name: Run tests
run: bun test --parallel
- name: Save Bun cache
if: env.SAVE_CACHE == 'true' && steps.bun-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ~/.bun/install/cache
key: bun-${{ hashFiles('dembrane/platform/bun.lock') }}
- name: Save type-check cache
if: env.SAVE_CACHE == 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: dembrane/platform/.cache
key: tsc-${{ github.sha }}
check-frontend:
name: 20-check-frontend
needs: plan
if: needs.plan.outputs.teardown != 'true' && needs.plan.outputs.frontend == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: dembrane/frontend
env:
SAVE_CACHE: ${{ github.event_name != 'pull_request' }}
steps:
- name: Check out code
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: { ref: "${{ env.REF }}", persist-credentials: false }
- name: Set up pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with: { version: 10 }
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: .tool-versions
- name: Find pnpm store
id: pnpm
run: echo "store=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm cache
id: pnpm-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.pnpm.outputs.store }}
key: pnpm-${{ runner.os }}-${{ hashFiles('dembrane/frontend/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
# A Mac's disk ignores case, Linux's doesn't: fails when a path or an import
# would mean a different file (or none) on one of them.
- name: Check path case
run: node scripts/check-path-case.mjs
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm exec tsc
# Fails when extract or compile changes a catalog, or when a locale lacks a message
# English has. Fill gaps locally with `pnpm translations:fill` (Gemini EU; entries land
# in each catalog's machine-translations.json for review) and commit the result.
- name: Check translations
run: |
pnpm messages:extract
pnpm messages:compile
node scripts/check-translations.mjs --strict
# The frontend builds without the platform folder, so it keeps a generated copy of the API types.
- name: Check API types are in sync
run: |
node scripts/sync-accounts-contract.mjs --check
node scripts/sync-error-codes.mjs --check
- name: Save pnpm cache
if: env.SAVE_CACHE == 'true' && steps.pnpm-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.pnpm.outputs.store }}
key: pnpm-${{ runner.os }}-${{ hashFiles('dembrane/frontend/pnpm-lock.yaml') }}
# The test suite runs beside the checks: together they took most of the frontend's time.
test-frontend:
name: 21-test-frontend
needs: plan
if: needs.plan.outputs.teardown != 'true' && needs.plan.outputs.frontend == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: dembrane/frontend
env:
SAVE_CACHE: ${{ github.event_name != 'pull_request' }}
steps:
- name: Check out code
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: { ref: "${{ env.REF }}", persist-credentials: false }
- name: Set up pnpm
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0
with: { version: 10 }
- name: Set up Node
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version-file: .tool-versions
- name: Find pnpm store
id: pnpm
run: echo "store=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Restore pnpm cache
id: pnpm-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.pnpm.outputs.store }}
key: pnpm-${{ runner.os }}-${{ hashFiles('dembrane/frontend/pnpm-lock.yaml') }}
restore-keys: pnpm-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Vitest's prebundled dependencies and its timing file, which orders slow files first.
- name: Restore Vitest cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: dembrane/frontend/node_modules/.vite/vitest
key: vitest-${{ runner.os }}-${{ hashFiles('dembrane/frontend/pnpm-lock.yaml', 'dembrane/frontend/vite.config.ts') }}-${{ github.sha }}
restore-keys: |
vitest-${{ runner.os }}-${{ hashFiles('dembrane/frontend/pnpm-lock.yaml', 'dembrane/frontend/vite.config.ts') }}-
vitest-${{ runner.os }}-
# Every vitest file under src; the Playwright specs in e2e/ are excluded in vite.config.ts.
- name: Run tests
run: pnpm exec vitest run
- name: Save pnpm cache
if: env.SAVE_CACHE == 'true' && steps.pnpm-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.pnpm.outputs.store }}
key: pnpm-${{ runner.os }}-${{ hashFiles('dembrane/frontend/pnpm-lock.yaml') }}
- name: Save Vitest cache
if: env.SAVE_CACHE == 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: dembrane/frontend/node_modules/.vite/vitest
key: vitest-${{ runner.os }}-${{ hashFiles('dembrane/frontend/pnpm-lock.yaml', 'dembrane/frontend/vite.config.ts') }}-${{ github.sha }}
# Builds the images whose inputs changed and proves they start. A run that deploys a PR
# preview or staging also pushes them, to that environment's registry, and tags every image
# it did not need to build for this commit, so the deploy job only rolls out. prod pushes in
# its own job, after the prod environment's approval.
images:
name: 40-build-images
needs: plan
if: >-
needs.plan.outputs.teardown != 'true'
&& (needs.plan.outputs.images != '' || needs.plan.outputs.deploy_pr == 'true' || needs.plan.outputs.deploy_env == 'staging')
runs-on: ubuntu-latest
timeout-minutes: 12
# The preview trust accepts a PR's jobs only in this environment. It has no reviewers; it
# marks the jobs that may push and deploy a preview. An empty name is no environment.
environment: ${{ needs.plan.outputs.deploy_pr == 'true' && 'pr-preview' || '' }}
permissions:
contents: read
id-token: write
env:
TAG: ${{ needs.plan.outputs.sha }}
PUSH: ${{ needs.plan.outputs.deploy_pr == 'true' && 'preview' || needs.plan.outputs.deploy_env == 'staging' && 'staging' || '' }}
REGISTRY: ${{ needs.plan.outputs.deploy_env == 'staging' && 'europe-west4-docker.pkg.dev/dembrane-web-staging/echo-staging' || 'europe-west4-docker.pkg.dev/dembrane-web-previews/echo-preview' }}
IMAGES: ${{ needs.plan.outputs.images }}
steps:
- name: Check out code
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: { ref: "${{ env.REF }}", persist-credentials: false }
# The runner is thrown away after the job, and removing the builder took half a minute.
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with: { cleanup: false }
- name: Authenticate to Google Cloud (preview)
if: env.PUSH == 'preview'
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13
with:
workload_identity_provider: projects/218237812097/locations/global/workloadIdentityPools/echo-preview-github/providers/github
service_account: echo-preview-deployer@dembrane-web-previews.iam.gserviceaccount.com
- name: Authenticate to Google Cloud (staging)
if: env.PUSH == 'staging'
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13
with:
workload_identity_provider: projects/1089877593337/locations/global/workloadIdentityPools/echo-staging-github/providers/github
service_account: echo-staging-deployer@dembrane-web-staging.iam.gserviceaccount.com
- name: Set up gcloud
if: env.PUSH != ''
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1
with: { skip_install: true }
- name: Configure Docker for Artifact Registry
if: env.PUSH != ''
run: gcloud auth configure-docker europe-west4-docker.pkg.dev --quiet
- name: Build images
id: build
run: |
built=$RUNNER_TEMP/built
# shellcheck disable=SC2086
if [ -n "$PUSH" ]; then BUILT_FILE=$built $GITHUB_WORKSPACE/.github/scripts/build-images.sh --push
else BUILT_FILE=$built $GITHUB_WORKSPACE/.github/scripts/build-images.sh --load $IMAGES; fi
echo "built=$(tr '\n' ' ' < "$built")" >> "$GITHUB_OUTPUT"
- name: Smoke test containers
if: steps.build.outputs.built != ''
env:
BUILT: ${{ steps.build.outputs.built }}
run: |
# Each binary checks at boot that its image carries the files it reads and exits
# non-zero naming any that are missing. The worker and migrate job need a database
# to go further, so they stop after the check. Only images built in this run are
# here; a reused one passed this test when it was built.
built() { grep -qw "$1" <<<"$BUILT"; }
if built worker; then docker run --rm "$REGISTRY/worker:$TAG" --check-assets; fi
if built migrate; then docker run --rm "$REGISTRY/migrate:$TAG" --check-assets; fi
if built api; then
docker run -d --name api -p 8080:8080 -e APP_ENV=test -e DATABASE_URL=postgres://u:p@127.0.0.1:1/d \
-e AUTH_SECRET=smoke-test-secret-that-is-long-enough-000 -e INVITE_HASH_SECRET=smoke-test-invite-secret-000 \
"$REGISTRY/api:$TAG"
ok=
for _ in $(seq 1 25); do curl -sf localhost:8080/health && ok=1 && break; sleep 0.2; done
[ -n "$ok" ] || { docker logs api; exit 1; }
# Asset-backed routes that need no database: popcorn's logo and a drawing read from
# /app/assets, the same tree the deck page template comes from.
for f in logo.png illustrations/scan.webp; do
curl -sf "localhost:8080/api/v2/popcorn/public/smoke/$f" -o /dev/null || { docker logs api; exit 1; }
done
fi
if built media; then
# The media image must ship the ffmpeg it runs, not only answer HTTP.
docker run -d --name media -p 8081:8080 -e APP_ENV=test "$REGISTRY/media:$TAG"
for _ in $(seq 1 25); do curl -sf localhost:8081/health && break; sleep 0.2; done
curl -sf -X POST localhost:8081/probe-url -H 'content-type: application/json' -d '{"url":"http://127.0.0.1:9/x.mp3"}' -o /dev/null -w '%{http_code}' | grep -q 422 \
|| { docker logs media; exit 1; }
fi
# The participant portal loads on phones at events, so its first download must not grow.
# Measured on the bundle the web image just built (its manifest sits beside the served
# files), so the frontend is bundled once per commit.
- name: Check portal bundle size
if: contains(steps.build.outputs.built, 'web')
run: |
dist=$RUNNER_TEMP/web-dist
id=$(docker create "$REGISTRY/web:$TAG")
docker cp "$id:/app/dist" "$dist"
docker cp "$id:/app/build-meta/.vite" "$dist/.vite"
docker rm "$id" >/dev/null
node ../frontend/scripts/check-portal-bundle.mjs --dist "$dist"
# A PR preview: its own services and database, from the images 40-build-images pushed.
# Serialized across PRs so two new previews cannot both take the last slot. It runs when a
# check failed too, only to say so in the PR comment. A check the plan skipped counts as
# passed: its side did not change.
deploy-pr:
name: 50-deploy-pr-preview
if: ${{ !cancelled() && needs.plan.outputs.deploy_pr == 'true' }}
needs: [plan, check-server, check-frontend, test-frontend, images]
runs-on: ubuntu-latest
timeout-minutes: 20
environment: pr-preview
concurrency:
group: preview-slots
cancel-in-progress: false
permissions:
contents: read
id-token: write
pull-requests: write
env:
TAG: ${{ needs.plan.outputs.sha }}
PR: ${{ needs.plan.outputs.pr }}
GH_TOKEN: ${{ github.token }}
steps:
- name: Check out code
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: { ref: "${{ env.REF }}", persist-credentials: false }
- name: Stop when a check failed
id: gate
env:
RESULTS: |
10-check-server=${{ needs.check-server.result }}
20-check-frontend=${{ needs.check-frontend.result }}
21-test-frontend=${{ needs.test-frontend.result }}
40-build-images=${{ needs.images.result }}
run: |
failed=$(awk -F= '$2 != "success" && $2 != "skipped" { printf "%s%s", (n++ ? ", " : ""), $1 }' <<<"$RESULTS")
[ -z "$failed" ] && exit 0
echo "failed=$failed" >> "$GITHUB_OUTPUT"
echo "::error::Not deploying: $failed did not pass"
exit 1
# Only a first deploy is announced as it begins. The preview comment remembers the
# message, so the step that ends this run, or a later run, edits it.
- name: Tell #alerts-ci a preview is being created
continue-on-error: true
env:
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.base_ref }}
run: $GITHUB_WORKSPACE/.github/scripts/ci-notify.sh preview starting "$PR"
- name: Authenticate to Google Cloud
id: auth
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13
with:
workload_identity_provider: projects/218237812097/locations/global/workloadIdentityPools/echo-preview-github/providers/github
service_account: echo-preview-deployer@dembrane-web-previews.iam.gserviceaccount.com
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1
with: { skip_install: true }
- name: Make room (at most 3 previews)
id: room
env:
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
run: |
set -o pipefail
removed=$($GITHUB_WORKSPACE/.github/scripts/deploy-env.sh make-room "$PR" | tee /dev/stderr | awk '/^removed PR preview / { print $4 }')
for n in $removed; do
$GITHUB_WORKSPACE/.github/scripts/ci-notify.sh preview removed "$n" "it made room for #$PR" || true
done
- name: Deploy and verify
id: deploy
# pipefail: a deploy that fails must fail the step, not be hidden by tail.
run: |
set -o pipefail
out=$($GITHUB_WORKSPACE/.github/scripts/deploy-env.sh deploy "pr-$PR" "$TAG" | tail -n 1)
echo "$out"
echo "Preview for #$PR: ${out// / }" >> "$GITHUB_STEP_SUMMARY"
# The PR comment and #alerts-ci: a failure here is reported here, where the comment that
# remembers the preview's Slack thread can be edited.
- name: Comment on PR and tell #alerts-ci
if: ${{ !cancelled() }}
env:
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: ${{ github.base_ref }}
CHECKS_FAILED: ${{ steps.gate.outputs.failed }}
OUTCOMES: |
gate=${{ steps.gate.outcome }}
Authenticate to Google Cloud=${{ steps.auth.outcome }}
Make room=${{ steps.room.outcome }}
Deploy and verify=${{ steps.deploy.outcome }}
run: |
failed=$(awk -F= '$2 == "failure" { print $1; exit }' <<<"$OUTCOMES")
[ "$failed" = gate ] && failed="$CHECKS_FAILED (nothing was deployed)"
if [ -n "$failed" ]; then $GITHUB_WORKSPACE/.github/scripts/ci-notify.sh preview failed "$PR" "$failed"
else $GITHUB_WORKSPACE/.github/scripts/ci-notify.sh preview deployed "$PR"; fi
teardown-pr:
name: 51-teardown-pr-preview
if: needs.plan.outputs.teardown == 'true'
needs: plan
runs-on: ubuntu-latest
timeout-minutes: 10
environment: pr-preview
concurrency:
group: preview-slots
cancel-in-progress: false
permissions:
contents: read
id-token: write
pull-requests: write
env:
PR: ${{ needs.plan.outputs.pr }}
steps:
- name: Check out scripts
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
sparse-checkout: |
.github/scripts
dembrane/infra
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13
with:
workload_identity_provider: projects/218237812097/locations/global/workloadIdentityPools/echo-preview-github/providers/github
service_account: echo-preview-deployer@dembrane-web-previews.iam.gserviceaccount.com
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1
with: { skip_install: true }
# Only the scripts and infra are checked out here, so there is no dembrane/platform.
- name: Tear down preview
working-directory: .
run: $GITHUB_WORKSPACE/.github/scripts/deploy-env.sh teardown "$PR"
- name: Update PR comment and tell #alerts-ci
working-directory: .
env:
GH_TOKEN: ${{ github.token }}
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
WHY: ${{ github.event.action == 'closed' && (github.event.pull_request.merged && 'the PR was merged' || 'the PR was closed') || 'the preview label was removed' }}
run: $GITHUB_WORKSPACE/.github/scripts/ci-notify.sh preview removed "$PR" "$WHY"
# staging rolls out what 40-build-images pushed, from its own project's registry with its own
# identity; prod builds and pushes here, after the approval. A check the plan skipped counts as
# passed, as on a PR preview.
deploy-staging:
name: 60-deploy-staging
if: ${{ !cancelled() && !failure() && needs.plan.outputs.deploy_env == 'staging' }}
needs: [plan, check-server, check-frontend, test-frontend, images]
runs-on: ubuntu-latest
timeout-minutes: 20
# Records each deploy as a GitHub Deployment: the last successful one is where the next
# deploy's list of PRs starts.
environment: staging
concurrency:
group: deploy-staging
cancel-in-progress: false
permissions:
contents: read
deployments: read
id-token: write
pull-requests: write
env:
TAG: ${{ needs.plan.outputs.sha }}
REGISTRY: europe-west4-docker.pkg.dev/dembrane-web-staging/echo-staging
steps:
- name: Check out code
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: { ref: "${{ env.REF }}", persist-credentials: false }
# One message in #alerts-ci per deploy: posted here, edited by the step that ends the job.
- name: Tell #alerts-ci the deploy is starting
id: start
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
run: $GITHUB_WORKSPACE/.github/scripts/release.sh announce-start staging "$TAG"
- name: Authenticate to Google Cloud
id: auth
uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed # v2.1.13
with:
workload_identity_provider: projects/1089877593337/locations/global/workloadIdentityPools/echo-staging-github/providers/github
service_account: echo-staging-deployer@dembrane-web-staging.iam.gserviceaccount.com
- name: Set up gcloud
uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f # v2.2.1
with: { skip_install: true }
- name: Deploy and verify
id: deploy
env:
HOLD_DATA: ${{ inputs.hold_data && '1' || '0' }}
run: $GITHUB_WORKSPACE/.github/scripts/deploy-env.sh deploy staging "$TAG"
# The deploy stands even when a message fails to send.
- name: Announce deploy
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
SLACK_TS: ${{ steps.start.outputs.slack_ts }}
run: $GITHUB_WORKSPACE/.github/scripts/release.sh announce-staging "$TAG"
- name: Tell #alerts-ci the deploy stopped
if: ${{ failure() || cancelled() }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
SLACK_TS: ${{ steps.start.outputs.slack_ts }}
STOPPED: ${{ job.status == 'cancelled' && 'cancelled' || steps.auth.outcome == 'failure' && 'Authenticate to Google Cloud' || steps.deploy.outcome == 'failure' && 'Deploy and verify' || '' }}
run: $GITHUB_WORKSPACE/.github/scripts/release.sh announce-failed staging "$TAG" "$STOPPED"
# echo-next runs main through prod's release path, with no tag and no approval. The job holds
# the registry login and the echo-gitops token and nothing else: it has no cluster credentials,
# and gitops-bump.sh echo-next writes values-echo-next.yaml, a file the prod app does not read.
# Argo CD on the dev cluster syncs dembrane-web-dummy by itself.
deploy-echo-next:
name: 65-deploy-echo-next
if: ${{ !cancelled() && !failure() && needs.plan.outputs.deploy_next == 'true' }}
needs: [plan, check-server, check-frontend, test-frontend, images]
runs-on: ubuntu-latest
timeout-minutes: 30
concurrency:
group: deploy-echo-next
cancel-in-progress: false
permissions:
contents: read
env:
TAG: ${{ needs.plan.outputs.sha }}
REGISTRY: registry.digitalocean.com/dbr-cr
IMAGE_PREFIX: dembrane-web-
GITOPS_BRANCH: ${{ vars.GITOPS_PROD_BRANCH || 'prod-v3' }}
steps:
- name: Check out code
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: { ref: "${{ env.REF }}", persist-credentials: false }
- name: Log in to the DigitalOcean registry
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: registry.digitalocean.com
username: ${{ secrets.DO_REGISTRY_USERNAME }}
password: ${{ secrets.DO_REGISTRY_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with: { cleanup: false }
# Tags an image already in the registry with the same inputs; builds the rest. A later
# release of this commit to prod finds all five there.
- name: Push images
run: $GITHUB_WORKSPACE/.github/scripts/build-images.sh --push
- name: Check out Dembrane/echo-gitops
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
repository: Dembrane/echo-gitops
ref: ${{ env.GITOPS_BRANCH }}
token: ${{ secrets.GITOPS_REPO_TOKEN }}
path: gitops
- name: Update the image tag in echo-gitops
working-directory: .
run: .github/scripts/gitops-bump.sh gitops "$GITOPS_BRANCH" echo-next "$TAG"
# Argo CD notices the commit within a few minutes, migrates with the contract held, rolls
# out and smoke-tests.
- name: Wait for the rollout
working-directory: .
run: |
api=https://api.echo-next.dembrane.com
end=$((SECONDS + 900))
until [ "$(curl -sf "$api/health" | jq -r .release 2>/dev/null)" = "$TAG" ]; do
[ "$SECONDS" -lt "$end" ] || { echo "::error::$api did not serve $TAG within 15 minutes; check the dembrane-web-dummy sync in Argo CD on the dev cluster"; exit 1; }
sleep 10
done
echo "$api serves $TAG" | tee -a "$GITHUB_STEP_SUMMARY"
# Pushes the release's images to the DigitalOcean registry and points Dembrane/echo-gitops at
# them. Argo CD does the rollout (migrate hook, rollout, smoke hook), so this job never touches
# the cluster.
deploy-prod:
name: 70-deploy-prod
if: ${{ !cancelled() && !failure() && needs.plan.outputs.deploy_env == 'prod' }}
needs: [plan, check-server, check-frontend, test-frontend, images]
runs-on: ubuntu-latest
timeout-minutes: 30
# The prod environment's required reviewers approve each deploy before the job runs.
environment: prod
concurrency:
group: deploy-prod
cancel-in-progress: false
permissions:
contents: write
pull-requests: write
env:
TAG: ${{ needs.plan.outputs.sha }}
RELEASE_TAG: ${{ needs.plan.outputs.release_tag }}
REGISTRY: registry.digitalocean.com/dbr-cr
IMAGE_PREFIX: dembrane-web-
GITOPS_BRANCH: ${{ inputs.gitops_branch || vars.GITOPS_PROD_BRANCH || 'prod-v3' }}
steps:
- name: Check out code
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with: { ref: "${{ env.REF }}", persist-credentials: false }
# One message in #alerts-ci per deploy: posted here, once the deploy is approved, and
# edited by the step that ends the job.
- name: Tell #alerts-ci the deploy is starting
id: start
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
run: $GITHUB_WORKSPACE/.github/scripts/release.sh announce-start prod "$RELEASE_TAG"
- name: Log in to the DigitalOcean registry
id: login
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1 # v3.5.0
with:
registry: registry.digitalocean.com
username: ${{ secrets.DO_REGISTRY_USERNAME }}
password: ${{ secrets.DO_REGISTRY_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
with: { cleanup: false }
# Tags an image already in the registry with the same inputs; builds the rest.
- name: Push images
id: images
run: $GITHUB_WORKSPACE/.github/scripts/build-images.sh --push
- name: Check out Dembrane/echo-gitops
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
repository: Dembrane/echo-gitops
ref: ${{ env.GITOPS_BRANCH }}
token: ${{ secrets.GITOPS_REPO_TOKEN }}
path: gitops
- name: Update the image tag in echo-gitops
id: bump
working-directory: .
run: .github/scripts/gitops-bump.sh gitops "$GITOPS_BRANCH" prod "$TAG" "$RELEASE_TAG"
# Until the cutover Argo CD syncs dembrane-web-prod by hand and api.dembrane.com is the old
# stack, so there is nothing to wait for. After it, the release is announced once it serves.
- name: Wait for the rollout
id: wait
if: vars.PROD_WAIT_FOR_ROLLOUT == 'true'
working-directory: .
run: |
api=https://api.dembrane.com
end=$((SECONDS + 900))
until [ "$(curl -sf "$api/health" | jq -r .release 2>/dev/null)" = "$TAG" ]; do
[ "$SECONDS" -lt "$end" ] || { echo "::error::$api did not serve $TAG within 15 minutes; check the dembrane-web-prod sync in Argo CD"; exit 1; }
sleep 10
done
until curl -sf "$api/ready/worker?release=$TAG" >/dev/null; do
[ "$SECONDS" -lt "$end" ] || { echo "::error::no worker of $TAG heartbeat within 15 minutes"; exit 1; }
sleep 5
done
echo "$api serves $TAG and a worker of $TAG is running"
# The GitHub Release, #team-engineering, #alerts-ci, a comment on each PR, and release.published to
# sam (skipped with a notice while its secrets are empty). The deploy stands even when
# one of these fails.
- name: Publish release
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
SAM_RELEASE_WEBHOOK_URL: ${{ secrets.SAM_RELEASE_WEBHOOK_URL }}
SAM_RELEASE_WEBHOOK_SECRET: ${{ secrets.SAM_RELEASE_WEBHOOK_SECRET }}
SLACK_TS: ${{ steps.start.outputs.slack_ts }}
run: $GITHUB_WORKSPACE/.github/scripts/release.sh publish-release "$RELEASE_TAG"
- name: Tell #alerts-ci the deploy stopped
if: ${{ failure() || cancelled() }}
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
SLACK_TS: ${{ steps.start.outputs.slack_ts }}
STOPPED: ${{ job.status == 'cancelled' && 'cancelled' || steps.login.outcome == 'failure' && 'Log in to the DigitalOcean registry' || steps.images.outcome == 'failure' && 'Push images' || steps.bump.outcome == 'failure' && 'Update the image tag in echo-gitops' || steps.wait.outcome == 'failure' && 'Wait for the rollout' || '' }}
run: $GITHUB_WORKSPACE/.github/scripts/release.sh announce-failed prod "$RELEASE_TAG" "$STOPPED"
# A failed run tells #alerts-ci which environment was not updated and what stopped it, except
# checks on a PR without a preview (they stay in the PR) and a staging, prod or preview deploy
# job that failed (it has said so itself, in the message it posted when it began). echo-next
# posts nothing when it is updated, so its failed deploy is told here.
notify-failure:
name: 90-notify-failure
if: >-
failure()
&& ((github.event_name != 'pull_request' && needs.deploy-staging.result != 'failure' && needs.deploy-prod.result != 'failure')
|| needs.deploy-echo-next.result == 'failure'
|| (needs.plan.outputs.deploy_pr == 'true' && needs.deploy-pr.result != 'failure')
|| needs.plan.outputs.teardown == 'true')
needs: [plan, check-server, check-frontend, test-frontend, images, deploy-pr, teardown-pr, deploy-staging, deploy-echo-next, deploy-prod]
runs-on: ubuntu-latest
timeout-minutes: 2
permissions:
contents: read
actions: read
steps:
- name: Check out scripts
uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0
with:
persist-credentials: false
sparse-checkout: .github/scripts
- name: Post to #alerts-ci
working-directory: .
env:
GH_TOKEN: ${{ github.token }}
SLACK_TOKEN: ${{ secrets.SLACK_ALERTS_BOT_TOKEN }}
DEPLOY_ENV: ${{ needs.deploy-echo-next.result == 'failure' && 'echo-next' || needs.plan.outputs.deploy_env || (needs.plan.outputs.deploy_next == 'true' && 'echo-next') || '' }}
PR: ${{ needs.plan.outputs.pr }}
TEARDOWN: ${{ needs.plan.outputs.teardown }}
NEEDS: ${{ toJSON(needs) }}
run: $GITHUB_WORKSPACE/.github/scripts/ci-notify.sh failure