Repository navigation
Best practices (sample): a sample project in every workspace #442
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: 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 |