CI workflow (tough-dev-school/education-backend)
The CI workflow from tough-dev-school/education-backend, explained and optimized by Latchkey.
CI health: C - fair
Point runs-on at Latchkey and get run de-duplication, job timeouts, SHA-pinned actions, self-healing for flaky steps, and up to 58% lower cost, applied automatically.
What it does
This is the CI workflow from the tough-dev-school/education-backend repository, a real project running GitHub Actions. It is shown here with attribution under its MIT license.
Below, Latchkey shows a faster, safer version produced by its optimization engine.
The workflow
name: CI
on:
push:
branches:
- master
pull_request:
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build
uses: ./.github/actions/build
- name: Restore mypy cache
uses: actions/cache@v5
with:
path: .mypy_cache
key: mypy-cache-${{ github.ref_name }}-v2
restore-keys: mypy-cache-master-v2
- name: Lint
run: make lint
- name: Lint the Dockerfile
uses: hadolint/hadolint-action@v3.3.0
- name: Install xmllint
uses: awalsh128/cache-apt-pkgs-action@v1
with:
packages: libxml2-utils
version: 1
- name: Lint XML files
run: find src/core -type f -name '*.xml'|xargs xmllint --noout
test:
needs: lint
runs-on: ${{ contains(github.triggering_actor, '[bot]') && 'ubuntu-24.04' || 'ubuntu-8x' }}
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_PASSWORD: secret
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
redis:
image: redis:6.2.20-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v6
- name: Build
uses: ./.github/actions/build
- name: Install locale stuff
uses: awalsh128/cache-apt-pkgs-action@v1
with:
packages: locales-all gettext
version: 1
- name: Get number of CPU cores
uses: SimenB/github-actions-cpu-cores@v2.0.0
id: cpu-cores
- name: test
env:
DATABASE_URL: postgres://postgres:secret@localhost:5432/postgres
REDISCLOUD_URL: redis://localhost:6379/5
run: make test -e SIMULTANEOUS_TEST_JOBS=${{ steps.cpu-cores.outputs.count }}
- name: Publish test results
uses: phoenix-actions/test-reporting@v15
if: ${{ !contains(github.triggering_actor, '[bot]') && github.event.pull_request.head.repo.full_name == github.repository }}
with:
name: Test results
path: "src/junit-*.xml"
reporter: java-junit
build-docker-image:
needs: test
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Build
uses: ./.github/actions/build
- name: Set up qemu
uses: docker/setup-qemu-action@v3
- name: Set up buildx
uses: docker/setup-buildx-action@v3
- name: Generate image identifier
id: image
uses: ASzc/change-string-case-action@v6
with:
string: ${{ github.repository_owner }}
- name: Login to ghcr
uses: docker/login-action@v3
if: ${{ github.ref == 'refs/heads/master' }}
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build web image
uses: docker/build-push-action@v6
with:
context: .
target: web
push: ${{ github.ref == 'refs/heads/master' }}
tags: |
ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-web:latest
ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-web:${{ github.sha }}
build-args: |
PYTHON_VERSION=${{ env.python-version }}
RELEASE=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build worker image
uses: docker/build-push-action@v6
with:
context: .
target: worker
push: ${{ github.ref == 'refs/heads/master' }}
tags: |
ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:latest
ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:${{ github.sha }}
build-args: |
PYTHON_VERSION=${{ env.python-version }}
RELEASE=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build scheduler image
uses: docker/build-push-action@v6
with:
context: .
target: scheduler
push: ${{ github.ref == 'refs/heads/master' }}
tags: |
ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-scheduler:latest
ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-scheduler:${{ github.sha }}
build-args: |
PYTHON_VERSION=${{ env.python-version }}
RELEASE=${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-docker-image
if: github.ref == 'refs/heads/master' && github.repository_owner == 'tough-dev-school'
runs-on: ubuntu-24.04
steps:
- name: checkout
uses: actions/checkout@v6
- name: Read image identifiers
id: image
uses: ASzc/change-string-case-action@v6
with:
string: ${{ github.repository_owner }}
- name: Update backend image
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: circle
key: ${{ secrets.DEPLOY_KEY }}
script: |
docker service update app_backend --replicas 1
docker service update app_backend --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-web:${{ github.sha }} --with-registry-auth
docker service update app_backend --replicas 2 -d
- name: Update celery worker image
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: circle
key: ${{ secrets.DEPLOY_KEY }}
script: docker service update app_worker --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:${{ github.sha }} --with-registry-auth
- name: Update amocrm worker image
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: circle
key: ${{ secrets.DEPLOY_KEY }}
script: docker service update app_amocrm-worker --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:${{ github.sha }} --with-registry-auth
- name: Update notion worker image
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: circle
key: ${{ secrets.DEPLOY_KEY }}
script: docker service update app_notion-worker --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:${{ github.sha }} --with-registry-auth
- name: Update celery scheduler image
uses: appleboy/ssh-action@v1.2.5
with:
host: ${{ secrets.DEPLOY_HOST }}
username: circle
key: ${{ secrets.DEPLOY_KEY }}
script: |
docker service update app_scheduler --detach --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-scheduler:${{ github.sha }} --with-registry-auth
The same workflow, on Latchkey
Removes redundant runs and caps runaway jobs. Added and changed lines are highlighted.
name: CI on: push: branches: - master pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: lint: timeout-minutes: 30 runs-on: latchkey-small steps: - name: Checkout uses: actions/checkout@v6 - name: Build uses: ./.github/actions/build - name: Restore mypy cache uses: actions/cache@v5 with: path: .mypy_cache key: mypy-cache-${{ github.ref_name }}-v2 restore-keys: mypy-cache-master-v2 - name: Lint run: make lint - name: Lint the Dockerfile uses: hadolint/hadolint-action@v3.3.0 - name: Install xmllint uses: awalsh128/cache-apt-pkgs-action@v1 with: packages: libxml2-utils version: 1 - name: Lint XML files run: find src/core -type f -name '*.xml'|xargs xmllint --noout test: timeout-minutes: 30 needs: lint runs-on: ${{ contains(github.triggering_actor, '[bot]') && 'ubuntu-24.04' || 'ubuntu-8x' }} services: postgres: image: postgres:15-alpine env: POSTGRES_PASSWORD: secret options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 redis: image: redis:6.2.20-alpine ports: - 6379:6379 steps: - uses: actions/checkout@v6 - name: Build uses: ./.github/actions/build - name: Install locale stuff uses: awalsh128/cache-apt-pkgs-action@v1 with: packages: locales-all gettext version: 1 - name: Get number of CPU cores uses: SimenB/github-actions-cpu-cores@v2.0.0 id: cpu-cores - name: test env: DATABASE_URL: postgres://postgres:secret@localhost:5432/postgres REDISCLOUD_URL: redis://localhost:6379/5 run: make test -e SIMULTANEOUS_TEST_JOBS=${{ steps.cpu-cores.outputs.count }} - name: Publish test results uses: phoenix-actions/test-reporting@v15 if: ${{ !contains(github.triggering_actor, '[bot]') && github.event.pull_request.head.repo.full_name == github.repository }} with: name: Test results path: "src/junit-*.xml" reporter: java-junit build-docker-image: timeout-minutes: 30 needs: test runs-on: latchkey-small steps: - name: Checkout uses: actions/checkout@v6 - name: Build uses: ./.github/actions/build - name: Set up qemu uses: docker/setup-qemu-action@v3 - name: Set up buildx uses: docker/setup-buildx-action@v3 - name: Generate image identifier id: image uses: ASzc/change-string-case-action@v6 with: string: ${{ github.repository_owner }} - name: Login to ghcr uses: docker/login-action@v3 if: ${{ github.ref == 'refs/heads/master' }} with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build web image uses: docker/build-push-action@v6 with: context: . target: web push: ${{ github.ref == 'refs/heads/master' }} tags: | ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-web:latest ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-web:${{ github.sha }} build-args: | PYTHON_VERSION=${{ env.python-version }} RELEASE=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Build worker image uses: docker/build-push-action@v6 with: context: . target: worker push: ${{ github.ref == 'refs/heads/master' }} tags: | ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:latest ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:${{ github.sha }} build-args: | PYTHON_VERSION=${{ env.python-version }} RELEASE=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max - name: Build scheduler image uses: docker/build-push-action@v6 with: context: . target: scheduler push: ${{ github.ref == 'refs/heads/master' }} tags: | ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-scheduler:latest ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-scheduler:${{ github.sha }} build-args: | PYTHON_VERSION=${{ env.python-version }} RELEASE=${{ github.sha }} cache-from: type=gha cache-to: type=gha,mode=max deploy: timeout-minutes: 30 needs: build-docker-image if: github.ref == 'refs/heads/master' && github.repository_owner == 'tough-dev-school' runs-on: latchkey-small steps: - name: checkout uses: actions/checkout@v6 - name: Read image identifiers id: image uses: ASzc/change-string-case-action@v6 with: string: ${{ github.repository_owner }} - name: Update backend image uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.DEPLOY_HOST }} username: circle key: ${{ secrets.DEPLOY_KEY }} script: | docker service update app_backend --replicas 1 docker service update app_backend --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-web:${{ github.sha }} --with-registry-auth docker service update app_backend --replicas 2 -d - name: Update celery worker image uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.DEPLOY_HOST }} username: circle key: ${{ secrets.DEPLOY_KEY }} script: docker service update app_worker --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:${{ github.sha }} --with-registry-auth - name: Update amocrm worker image uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.DEPLOY_HOST }} username: circle key: ${{ secrets.DEPLOY_KEY }} script: docker service update app_amocrm-worker --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:${{ github.sha }} --with-registry-auth - name: Update notion worker image uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.DEPLOY_HOST }} username: circle key: ${{ secrets.DEPLOY_KEY }} script: docker service update app_notion-worker --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-worker:${{ github.sha }} --with-registry-auth - name: Update celery scheduler image uses: appleboy/ssh-action@v1.2.5 with: host: ${{ secrets.DEPLOY_HOST }} username: circle key: ${{ secrets.DEPLOY_KEY }} script: | docker service update app_scheduler --detach --image ghcr.io/${{ steps.image.outputs.lowercase }}/monolith-scheduler:${{ github.sha }} --with-registry-auth
What changed
- Run on Latchkey managed runners with one line (
runs-on), which apply the fixes below automatically and self-heal transient failures. This example useslatchkey-small; pick the runner size that fits the job. - Cancel superseded runs when a branch or PR gets a newer push.
- Add a job timeout so a hung step cannot burn hours of runner time.
10 third-party actions are referenced by a movable tag. Pin them to the commit SHA (Latchkey resolves and applies this automatically) so a repointed tag cannot change what runs.
What Latchkey heals here
This workflow has steps that commonly fail on transient issues (network, registries, flaky browsers). On Latchkey managed runners they are detected, retried, and self-healed instead of failing your build:
- Container pulls and builds
This workflow runs 4 jobs per trigger. On Latchkey the same minutes cost up to 58% less than GitHub-hosted, with zero queue time.