Skip to content

services

A database beside each test step, its own and ready before the step starts.

Tests that need a database get one of their own.

flowchart LR
    s0["lint"]
    s1["api tests"]
    s2["storage tests"]

The steps this script plans for a push about which nothing is known. An arrow is a needs.

# Tests that need a database get one of their own.
#
# A service is a container started beside a step and stopped with it. The step
# reaches it by its name, here `postgres`. It belongs to the step: the two
# test steps below run at the same time, each against its own empty database,
# so neither can see what the other wrote.

postgres = service(
    "postgres",
    image = "postgres:17",
    env = {"POSTGRES_USER": "app", "POSTGRES_PASSWORD": "app", "POSTGRES_DB": "app"},
    # The step starts once this succeeds inside the service's container. It
    # asks over TCP: while the image is setting a database up it runs a server
    # that answers only on its socket, and that one is not the one to wait for.
    ready = "pg_isready -h localhost -U app",
    # The image runs what it finds here as it sets the database up. The script
    # is the service's: the test steps are not given db/.
    files = {"db/roles.sql": "/docker-entrypoint-initdb.d/10-roles.sql"},
)

def tests(name, path):
    return step(
        name,
        image = "python:3.13-slim",
        run = "pip install -q -e '.[test]' && pytest -q " + path,
        inputs = ["src", "pyproject.toml", path, "tests/conftest.py"],
        env = {"DATABASE_URL": "postgresql://app:app@postgres:5432/app"},
        services = [postgres],
    )

def ci(ctx):
    return [
        step("lint", image = "python:3.13-slim", run = "pip install -q ruff && ruff check .", inputs = ["src", "tests", "pyproject.toml"]),
        tests("api tests", "tests/api"),
        tests("storage tests", "tests/storage"),
    ]
# Generated by `starci compile` from services.ci.star. Edit that, not this.
#
# Each job is one step of the plan: the same image, command, tools and
# packages, given only the step's inputs. A job skips its work when it has
# already passed with the same inputs.
#
# What does not carry over:
#  - Passes are remembered in this repository's Actions cache, which GitHub
#    scopes by branch and evicts when old. starci's own store has neither
#    limit.

name: CI

on:
  push:
    branches: ['**']
    tags: ['**']
  pull_request:
  workflow_dispatch:

jobs:
  lint:
    name: "lint"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          sparse-checkout-cone-mode: false
          sparse-checkout: |
            /pyproject.toml
            /src
            /tests
      - name: Has this already passed with these inputs?
        id: passed
        uses: actions/cache@v4
        with:
          path: ${{ runner.temp }}/starci-passed
          key: starci-passed-lint-d87a15139de0a6d7-${{ hashFiles('pyproject.toml', 'pyproject.toml/**', 'src', 'src/**', 'tests', 'tests/**') }}
      - name: "lint"
        if: steps.passed.outputs.cache-hit != 'true'
        env:
          STARCI_RUN: |-
            pip install -q ruff && ruff check .
        run: |
          docker run --rm \
            -v "$PWD:/workspace" \
            -w /workspace \
            python:3.13-slim sh -c "$STARCI_RUN"
      - name: Remember this pass
        if: steps.passed.outputs.cache-hit != 'true'
        run: mkdir -p "$RUNNER_TEMP/starci-passed" && date > "$RUNNER_TEMP/starci-passed/at"

  api-tests:
    name: "api tests"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          sparse-checkout-cone-mode: false
          sparse-checkout: |
            /pyproject.toml
            /src
            /tests/api
            /tests/conftest.py
            /db/roles.sql
      - name: Has this already passed with these inputs?
        id: passed
        uses: actions/cache@v4
        with:
          path: ${{ runner.temp }}/starci-passed
          key: starci-passed-api-tests-1b86b1a2577c889f-${{ hashFiles('pyproject.toml', 'pyproject.toml/**', 'src', 'src/**', 'tests/api', 'tests/api/**', 'tests/conftest.py', 'tests/conftest.py/**', 'db/roles.sql', 'db/roles.sql/**') }}
      - name: "Services: postgres"
        if: steps.passed.outputs.cache-hit != 'true'
        run: |
          mkdir -p "$(dirname "$RUNNER_TEMP/starci-services/api-tests"'/postgres/db/roles.sql')"
          cp -R 'db/roles.sql' "$RUNNER_TEMP/starci-services/api-tests"'/postgres/db/roles.sql'
          git sparse-checkout set --no-cone '/pyproject.toml' '/src' '/tests/api' '/tests/conftest.py'
          docker network create "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-api-tests"
          docker run -d --name "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-api-tests-postgres" --network "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-api-tests" --network-alias postgres --hostname postgres -e 'POSTGRES_DB=app' -e 'POSTGRES_PASSWORD=app' -e 'POSTGRES_USER=app' -v "$RUNNER_TEMP/starci-services/api-tests"'/postgres/db/roles.sql:/docker-entrypoint-initdb.d/10-roles.sql':ro postgres:17
          until=$(($(date +%s) + 120))
          until timeout 10 docker exec "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-api-tests-postgres" sh -c 'pg_isready -h localhost -U app'; do
            if [ "$(date +%s)" -ge "$until" ]; then
              echo "service postgres was not ready after 120 seconds"
              docker logs --tail 20 "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-api-tests-postgres"
              exit 1
            fi
            sleep 0.5
          done
      - name: "api tests"
        if: steps.passed.outputs.cache-hit != 'true'
        env:
          STARCI_RUN: |-
            pip install -q -e '.[test]' && pytest -q tests/api
        run: |
          docker run --rm \
            -v "$PWD:/workspace" \
            -w /workspace \
            --network "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-api-tests" \
            -e 'DATABASE_URL=postgresql://app:app@postgres:5432/app' \
            python:3.13-slim sh -c "$STARCI_RUN"
      - name: Stop the services
        if: always() && steps.passed.outputs.cache-hit != 'true'
        run: |
          docker rm -f -v "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-api-tests-postgres" || true
          docker network rm "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-api-tests" || true
      - name: Remember this pass
        if: steps.passed.outputs.cache-hit != 'true'
        run: mkdir -p "$RUNNER_TEMP/starci-passed" && date > "$RUNNER_TEMP/starci-passed/at"

  storage-tests:
    name: "storage tests"
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          sparse-checkout-cone-mode: false
          sparse-checkout: |
            /pyproject.toml
            /src
            /tests/conftest.py
            /tests/storage
            /db/roles.sql
      - name: Has this already passed with these inputs?
        id: passed
        uses: actions/cache@v4
        with:
          path: ${{ runner.temp }}/starci-passed
          key: starci-passed-storage-tests-9dd714af153780db-${{ hashFiles('pyproject.toml', 'pyproject.toml/**', 'src', 'src/**', 'tests/conftest.py', 'tests/conftest.py/**', 'tests/storage', 'tests/storage/**', 'db/roles.sql', 'db/roles.sql/**') }}
      - name: "Services: postgres"
        if: steps.passed.outputs.cache-hit != 'true'
        run: |
          mkdir -p "$(dirname "$RUNNER_TEMP/starci-services/storage-tests"'/postgres/db/roles.sql')"
          cp -R 'db/roles.sql' "$RUNNER_TEMP/starci-services/storage-tests"'/postgres/db/roles.sql'
          git sparse-checkout set --no-cone '/pyproject.toml' '/src' '/tests/conftest.py' '/tests/storage'
          docker network create "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-storage-tests"
          docker run -d --name "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-storage-tests-postgres" --network "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-storage-tests" --network-alias postgres --hostname postgres -e 'POSTGRES_DB=app' -e 'POSTGRES_PASSWORD=app' -e 'POSTGRES_USER=app' -v "$RUNNER_TEMP/starci-services/storage-tests"'/postgres/db/roles.sql:/docker-entrypoint-initdb.d/10-roles.sql':ro postgres:17
          until=$(($(date +%s) + 120))
          until timeout 10 docker exec "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-storage-tests-postgres" sh -c 'pg_isready -h localhost -U app'; do
            if [ "$(date +%s)" -ge "$until" ]; then
              echo "service postgres was not ready after 120 seconds"
              docker logs --tail 20 "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-storage-tests-postgres"
              exit 1
            fi
            sleep 0.5
          done
      - name: "storage tests"
        if: steps.passed.outputs.cache-hit != 'true'
        env:
          STARCI_RUN: |-
            pip install -q -e '.[test]' && pytest -q tests/storage
        run: |
          docker run --rm \
            -v "$PWD:/workspace" \
            -w /workspace \
            --network "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-storage-tests" \
            -e 'DATABASE_URL=postgresql://app:app@postgres:5432/app' \
            python:3.13-slim sh -c "$STARCI_RUN"
      - name: Stop the services
        if: always() && steps.passed.outputs.cache-hit != 'true'
        run: |
          docker rm -f -v "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-storage-tests-postgres" || true
          docker network rm "starci-$GITHUB_RUN_ID-${GITHUB_RUN_ATTEMPT:-1}-storage-tests" || true
      - name: Remember this pass
        if: steps.passed.outputs.cache-hit != 'true'
        run: mkdir -p "$RUNNER_TEMP/starci-passed" && date > "$RUNNER_TEMP/starci-passed/at"
# Generated by `starci compile --target circleci` from services.ci.star. Edit that, not this.
#
# Each job is one step of the plan: the same image, command, tools and
# packages, given only the step's inputs. A job skips its work when it has
# already passed with the same inputs.
#
# What does not carry over:
#  - Passes are remembered in this project's CircleCI cache, which expires.
#    starci's own store does not.

version: 2.1

jobs:
  lint:
    machine:
      image: ubuntu-2404:current
    resource_class: medium
    steps:
      - checkout
      - run:
          name: "Keep only this step's inputs"
          command: |
            git sparse-checkout set --no-cone '/pyproject.toml' '/src' '/tests'
      - run:
          name: "Identify the inputs"
          command: |
            git ls-files -s -- ':(top,literal)pyproject.toml' ':(top,literal)src' ':(top,literal)tests' > /tmp/starci-inputs
      - restore_cache:
          name: Has this already passed with these inputs?
          keys:
            - starci-passed-lint-d87a15139de0a6d7-{{ checksum "/tmp/starci-inputs" }}
      - run:
          name: "Stop here if it has"
          command: |
            if [ -e ~/starci-passed/at ]; then
              echo "Passed before with the same inputs, on $(cat ~/starci-passed/at). Nothing to do."
              circleci-agent step halt
            fi
      - run:
          name: "lint"
          environment:
            STARCI_RUN: |-
              pip install -q ruff && ruff check .
          command: |
            docker run --rm \
              -v "$PWD:/workspace" \
              -w /workspace \
              python:3.13-slim sh -c "$STARCI_RUN"
      - run:
          name: "Remember this pass"
          command: |
            mkdir -p ~/starci-passed && date > ~/starci-passed/at
      - save_cache:
          key: starci-passed-lint-d87a15139de0a6d7-{{ checksum "/tmp/starci-inputs" }}
          paths:
            - ~/starci-passed

  api-tests:
    machine:
      image: ubuntu-2404:current
    resource_class: medium
    steps:
      - checkout
      - run:
          name: "Files for the services"
          command: |
            mkdir -p "$(dirname "$HOME/starci-services"'/postgres/db/roles.sql')"
            cp -R 'db/roles.sql' "$HOME/starci-services"'/postgres/db/roles.sql'
      - run:
          name: "Keep only this step's inputs"
          command: |
            git sparse-checkout set --no-cone '/pyproject.toml' '/src' '/tests/api' '/tests/conftest.py'
      - run:
          name: "Identify the inputs"
          command: |
            git ls-files -s -- ':(top,literal)pyproject.toml' ':(top,literal)src' ':(top,literal)tests/api' ':(top,literal)tests/conftest.py' ':(top,literal)db/roles.sql' > /tmp/starci-inputs
      - restore_cache:
          name: Has this already passed with these inputs?
          keys:
            - starci-passed-api-tests-1b86b1a2577c889f-{{ checksum "/tmp/starci-inputs" }}
      - run:
          name: "Stop here if it has"
          command: |
            if [ -e ~/starci-passed/at ]; then
              echo "Passed before with the same inputs, on $(cat ~/starci-passed/at). Nothing to do."
              circleci-agent step halt
            fi
      - run:
          name: "Services: postgres"
          command: |
            docker network create "starci-$CIRCLE_BUILD_NUM-api-tests"
            docker run -d --name "starci-$CIRCLE_BUILD_NUM-api-tests-postgres" --network "starci-$CIRCLE_BUILD_NUM-api-tests" --network-alias postgres --hostname postgres -e 'POSTGRES_DB=app' -e 'POSTGRES_PASSWORD=app' -e 'POSTGRES_USER=app' -v "$HOME/starci-services"'/postgres/db/roles.sql:/docker-entrypoint-initdb.d/10-roles.sql':ro postgres:17
            until=$(($(date +%s) + 120))
            until timeout 10 docker exec "starci-$CIRCLE_BUILD_NUM-api-tests-postgres" sh -c 'pg_isready -h localhost -U app'; do
              if [ "$(date +%s)" -ge "$until" ]; then
                echo "service postgres was not ready after 120 seconds"
                docker logs --tail 20 "starci-$CIRCLE_BUILD_NUM-api-tests-postgres"
                exit 1
              fi
              sleep 0.5
            done
      - run:
          name: "api tests"
          environment:
            STARCI_RUN: |-
              pip install -q -e '.[test]' && pytest -q tests/api
          command: |
            docker run --rm \
              -v "$PWD:/workspace" \
              -w /workspace \
              --network "starci-$CIRCLE_BUILD_NUM-api-tests" \
              -e 'DATABASE_URL=postgresql://app:app@postgres:5432/app' \
              python:3.13-slim sh -c "$STARCI_RUN"
      - run:
          name: Stop the services
          when: always
          command: |
            docker rm -f -v "starci-$CIRCLE_BUILD_NUM-api-tests-postgres" || true
            docker network rm "starci-$CIRCLE_BUILD_NUM-api-tests" || true
      - run:
          name: "Remember this pass"
          command: |
            mkdir -p ~/starci-passed && date > ~/starci-passed/at
      - save_cache:
          key: starci-passed-api-tests-1b86b1a2577c889f-{{ checksum "/tmp/starci-inputs" }}
          paths:
            - ~/starci-passed

  storage-tests:
    machine:
      image: ubuntu-2404:current
    resource_class: medium
    steps:
      - checkout
      - run:
          name: "Files for the services"
          command: |
            mkdir -p "$(dirname "$HOME/starci-services"'/postgres/db/roles.sql')"
            cp -R 'db/roles.sql' "$HOME/starci-services"'/postgres/db/roles.sql'
      - run:
          name: "Keep only this step's inputs"
          command: |
            git sparse-checkout set --no-cone '/pyproject.toml' '/src' '/tests/conftest.py' '/tests/storage'
      - run:
          name: "Identify the inputs"
          command: |
            git ls-files -s -- ':(top,literal)pyproject.toml' ':(top,literal)src' ':(top,literal)tests/conftest.py' ':(top,literal)tests/storage' ':(top,literal)db/roles.sql' > /tmp/starci-inputs
      - restore_cache:
          name: Has this already passed with these inputs?
          keys:
            - starci-passed-storage-tests-9dd714af153780db-{{ checksum "/tmp/starci-inputs" }}
      - run:
          name: "Stop here if it has"
          command: |
            if [ -e ~/starci-passed/at ]; then
              echo "Passed before with the same inputs, on $(cat ~/starci-passed/at). Nothing to do."
              circleci-agent step halt
            fi
      - run:
          name: "Services: postgres"
          command: |
            docker network create "starci-$CIRCLE_BUILD_NUM-storage-tests"
            docker run -d --name "starci-$CIRCLE_BUILD_NUM-storage-tests-postgres" --network "starci-$CIRCLE_BUILD_NUM-storage-tests" --network-alias postgres --hostname postgres -e 'POSTGRES_DB=app' -e 'POSTGRES_PASSWORD=app' -e 'POSTGRES_USER=app' -v "$HOME/starci-services"'/postgres/db/roles.sql:/docker-entrypoint-initdb.d/10-roles.sql':ro postgres:17
            until=$(($(date +%s) + 120))
            until timeout 10 docker exec "starci-$CIRCLE_BUILD_NUM-storage-tests-postgres" sh -c 'pg_isready -h localhost -U app'; do
              if [ "$(date +%s)" -ge "$until" ]; then
                echo "service postgres was not ready after 120 seconds"
                docker logs --tail 20 "starci-$CIRCLE_BUILD_NUM-storage-tests-postgres"
                exit 1
              fi
              sleep 0.5
            done
      - run:
          name: "storage tests"
          environment:
            STARCI_RUN: |-
              pip install -q -e '.[test]' && pytest -q tests/storage
          command: |
            docker run --rm \
              -v "$PWD:/workspace" \
              -w /workspace \
              --network "starci-$CIRCLE_BUILD_NUM-storage-tests" \
              -e 'DATABASE_URL=postgresql://app:app@postgres:5432/app' \
              python:3.13-slim sh -c "$STARCI_RUN"
      - run:
          name: Stop the services
          when: always
          command: |
            docker rm -f -v "starci-$CIRCLE_BUILD_NUM-storage-tests-postgres" || true
            docker network rm "starci-$CIRCLE_BUILD_NUM-storage-tests" || true
      - run:
          name: "Remember this pass"
          command: |
            mkdir -p ~/starci-passed && date > ~/starci-passed/at
      - save_cache:
          key: starci-passed-storage-tests-9dd714af153780db-{{ checksum "/tmp/starci-inputs" }}
          paths:
            - ~/starci-passed

workflows:
  ci:
    jobs:
      - lint
      - api-tests
      - storage-tests

Download: services.ci.star, GitHub Actions, CircleCI