Skip to content

Commit dd01a9e

Browse files
amtiYoclaude
andcommitted
ci: add continuous integration, releases and a pulled production stack
Quoto had no .github/ at all -- the only bot in the family without one. Its tests ran when somebody remembered, no migration was proved to apply or to undo before it reached the host, and the image serving users was built on the host from a working copy, so nothing could say which commit it was. ci.yml runs the suite, compiles the sources, checks requirements.in is fully pinned into requirements.txt, applies the migrations against a real PostgreSQL 17 and steps one back down, refuses a branched alembic history, and builds the image. release.yml publishes to GHCR and creates the GitHub Release from the changelog section on a v* tag, refusing a tag that is not an ancestor of main. deploy/ws04/compose.yaml pulls that published image. The Dockerfile also stopped claiming to be 0.10.2 when built without an explicit version; it had said that through the whole of v0.10.3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FxNv1rk7jMQtb9tMBREc5B
1 parent b6f7ef7 commit dd01a9e

4 files changed

Lines changed: 317 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
name: CI
3+
4+
on:
5+
push:
6+
branches: ["**"]
7+
pull_request:
8+
9+
permissions:
10+
contents: read
11+
12+
concurrency:
13+
group: ci-${{ github.ref }}
14+
cancel-in-progress: true
15+
16+
env:
17+
# tests/__init__.py sets these too, but alembic loads app.models -> app.config,
18+
# which validates settings at import time and exits if they are missing.
19+
BOT_TOKEN: "123456:CITOKEN1234567890"
20+
BOT_USERNAME: quoto_ci_bot
21+
22+
jobs:
23+
test:
24+
name: Test
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
28+
29+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
30+
with:
31+
python-version: "3.12"
32+
cache: pip
33+
cache-dependency-path: requirements.txt
34+
35+
- name: Install dependencies
36+
run: |
37+
python -m pip install --upgrade pip
38+
pip install -r requirements.txt
39+
40+
- name: Every direct dependency is pinned in requirements.txt
41+
# requirements.in is the intent, requirements.txt is what installs. A
42+
# package added to the first and never compiled into the second is a
43+
# dependency the container will not have.
44+
run: |
45+
missing=0
46+
while read -r pkg; do
47+
case "$pkg" in ''|'#'*) continue ;; esac
48+
name=$(printf '%s' "$pkg" | sed 's/[<>=!~;[].*//' | tr -d '[:space:]')
49+
[ -z "$name" ] && continue
50+
grep -qiE "^${name}(\[|==)" requirements.txt || {
51+
echo "missing from requirements.txt: ${name}"; missing=1; }
52+
done < requirements.in
53+
exit "$missing"
54+
55+
- name: Compile all sources
56+
run: python -m compileall -q app tests scripts main.py healthcheck.py
57+
58+
- name: Test
59+
run: python -m pytest -q tests
60+
61+
migrations:
62+
name: Migrations
63+
runs-on: ubuntu-latest
64+
env:
65+
DB_URL: postgresql+asyncpg://quoto:quoto@localhost:5432/quoto
66+
PGPASSWORD: quoto
67+
services:
68+
postgres:
69+
image: postgres:17-alpine
70+
env:
71+
POSTGRES_USER: quoto
72+
POSTGRES_PASSWORD: quoto
73+
POSTGRES_DB: quoto
74+
options: >-
75+
--health-cmd "pg_isready -U quoto"
76+
--health-interval 5s --health-timeout 5s --health-retries 10
77+
ports: ["5432:5432"]
78+
steps:
79+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
80+
81+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
82+
with:
83+
python-version: "3.12"
84+
cache: pip
85+
cache-dependency-path: requirements.txt
86+
87+
- name: Install dependencies
88+
run: |
89+
python -m pip install --upgrade pip
90+
pip install -r requirements.txt
91+
92+
- name: Reject a branched migration history
93+
# Two heads mean two migrations claim the same parent. Alembic then
94+
# refuses to upgrade on the host, after the deploy has already started.
95+
run: |
96+
heads=$(alembic heads | grep -c . || true)
97+
if [ "$heads" -ne 1 ]; then
98+
echo "expected exactly one alembic head, found ${heads}:"
99+
alembic heads
100+
exit 1
101+
fi
102+
103+
- name: Seed the shared core schema
104+
# Production applies these from the core repository. deploy/core-init.sql
105+
# is the verbatim copy quoto's foreign keys need to exist first.
106+
run: psql -h localhost -U quoto -d quoto -v ON_ERROR_STOP=1 -f deploy/core-init.sql
107+
108+
- name: Upgrade, step back down, upgrade again
109+
# A migration that cannot be undone is a migration that cannot be rolled
110+
# back on the host either.
111+
run: |
112+
alembic upgrade head
113+
alembic downgrade -1
114+
alembic upgrade head
115+
116+
image:
117+
name: Docker build
118+
runs-on: ubuntu-latest
119+
steps:
120+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
121+
122+
- name: Build image
123+
run: docker build -t quoto:ci .
124+
125+
- name: Validate compose
126+
run: |
127+
cp .env.example .env
128+
docker compose config >/dev/null

‎.github/workflows/release.yml‎

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Steps 6-8 of docs/releases.md, run from the tag instead of by hand.
3+
name: Release
4+
5+
on:
6+
push:
7+
tags: ["v*"]
8+
9+
permissions:
10+
contents: write
11+
packages: write
12+
13+
jobs:
14+
verify:
15+
name: Verify release source
16+
runs-on: ubuntu-latest
17+
env:
18+
BOT_TOKEN: "123456:CITOKEN1234567890"
19+
BOT_USERNAME: quoto_ci_bot
20+
steps:
21+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
22+
with:
23+
fetch-depth: 0
24+
25+
- name: Require the tag commit on main
26+
# docs/versioning.md: every release is published from main. A tag on a
27+
# branch that was never merged would publish an image nobody reviewed.
28+
run: |
29+
git fetch --no-tags origin main:refs/remotes/origin/main
30+
if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then
31+
echo "tag ${GITHUB_REF_NAME} is not an ancestor of origin/main"
32+
exit 1
33+
fi
34+
35+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
36+
with:
37+
python-version: "3.12"
38+
cache: pip
39+
cache-dependency-path: requirements.txt
40+
41+
- name: Install dependencies
42+
run: |
43+
python -m pip install --upgrade pip
44+
pip install -r requirements.txt
45+
46+
- name: Test
47+
run: python -m pytest -q tests
48+
49+
- name: Require a changelog section for this tag
50+
run: |
51+
grep -qE "^## ${GITHUB_REF_NAME}( |$)" CHANGELOG.md \
52+
|| { echo "CHANGELOG.md has no '## ${GITHUB_REF_NAME}' section"; exit 1; }
53+
54+
release:
55+
name: Publish image and release
56+
needs: verify
57+
runs-on: ubuntu-latest
58+
steps:
59+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
60+
61+
- name: Resolve release metadata
62+
id: meta
63+
run: |
64+
version="${GITHUB_REF_NAME}"
65+
echo "version=${version}" >>"$GITHUB_OUTPUT"
66+
echo "date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >>"$GITHUB_OUTPUT"
67+
# A pre-release suffix (-alpha/-beta/-rc) is never tagged "latest".
68+
if printf '%s' "${version}" | grep -Eq -- '-(alpha|beta|rc)\.'; then
69+
echo "prerelease=true" >>"$GITHUB_OUTPUT"
70+
else
71+
echo "prerelease=false" >>"$GITHUB_OUTPUT"
72+
fi
73+
74+
- name: Lowercase image name
75+
id: image
76+
run: echo "repo=ghcr.io/${GITHUB_REPOSITORY,,}" >>"$GITHUB_OUTPUT"
77+
78+
- name: Extract changelog section
79+
id: changelog
80+
run: |
81+
version="${GITHUB_REF_NAME}"
82+
notes_file="$(mktemp)"
83+
# Match by literal prefix AND a boundary, so "## v0.10.3" does not
84+
# capture "## v0.10.3-alpha.1".
85+
awk -v ver="## ${version}" '
86+
index($0, ver) == 1 && (length($0) == length(ver) || substr($0, length(ver) + 1, 1) == " ") { capture=1; next }
87+
capture && /^## / { exit }
88+
capture { print }
89+
' CHANGELOG.md >"${notes_file}"
90+
if [ ! -s "${notes_file}" ]; then
91+
printf 'Release %s\n' "${version}" >"${notes_file}"
92+
fi
93+
echo "file=${notes_file}" >>"$GITHUB_OUTPUT"
94+
95+
- name: Log in to GitHub Container Registry
96+
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
97+
with:
98+
registry: ghcr.io
99+
username: ${{ github.actor }}
100+
password: ${{ secrets.GITHUB_TOKEN }}
101+
102+
- name: Set up Buildx
103+
uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0
104+
105+
- name: Build and push image
106+
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
107+
with:
108+
context: .
109+
push: true
110+
tags: ${{ steps.image.outputs.repo }}:${{ steps.meta.outputs.version }}
111+
build-args: |
112+
QUOTO_VERSION=${{ steps.meta.outputs.version }}
113+
QUOTO_REVISION=${{ github.sha }}
114+
115+
- name: Create GitHub Release
116+
env:
117+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
118+
run: |
119+
flags=""
120+
if [ "${{ steps.meta.outputs.prerelease }}" = "true" ]; then
121+
flags="--prerelease"
122+
fi
123+
# Idempotent: update an existing release (manual, or a re-run),
124+
# otherwise create it.
125+
if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
126+
gh release edit "${GITHUB_REF_NAME}" \
127+
--title "${GITHUB_REF_NAME}" \
128+
--notes-file "${{ steps.changelog.outputs.file }}" ${flags}
129+
else
130+
gh release create "${GITHUB_REF_NAME}" \
131+
--title "${GITHUB_REF_NAME}" \
132+
--notes-file "${{ steps.changelog.outputs.file }}" ${flags}
133+
fi

‎Dockerfile‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
FROM python:3.12-slim-bookworm
22

3-
ARG QUOTO_VERSION=0.10.2
3+
# No real default: the release workflow passes the tag. A version baked in
4+
# here goes stale the moment it is not bumped, and then the image reports a
5+
# release it is not.
6+
ARG QUOTO_VERSION=dev
47
ARG QUOTO_REVISION=unknown
58

69
LABEL org.opencontainers.image.title="Quoto" \

‎deploy/ws04/compose.yaml‎

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
# SPDX-License-Identifier: Apache-2.0
2+
# Quoto production stack on WS04. Runs against the shared core-postgres as
3+
# quoto_core (database `core`, schema `quoto`; the role's default search_path is
4+
# quoto,core).
5+
#
6+
# The image is pulled, never built here. A stack that builds from a working copy
7+
# on the host runs whatever that copy happened to contain, which is not
8+
# necessarily what CI tested and published -- and afterwards nothing on the host
9+
# can say which commit it was. QUOTO_IMAGE must name an immutable GHCR
10+
# reference, digest for preference:
11+
#
12+
# QUOTO_IMAGE=ghcr.io/freshlabdev/quoto@sha256:<digest>
13+
#
14+
# The retired local `db` service is deliberately absent. It still exists in the
15+
# stack directory on the host as a rollback anchor for the 0.9.0 core
16+
# consolidation, together with its ./postgres data directory; it is not part of
17+
# the deployment and must not be started by it.
18+
name: quoto
19+
20+
services:
21+
app:
22+
image: ${QUOTO_IMAGE:?QUOTO_IMAGE must be an immutable GHCR reference}
23+
container_name: quoto-bot
24+
restart: unless-stopped
25+
env_file:
26+
- path: .app.env
27+
required: true
28+
environment:
29+
# Overrides the DB_URL carried in .app.env, which still points at the
30+
# retired local database.
31+
DB_URL: postgresql+asyncpg://quoto_core:${QUOTO_CORE_PASSWORD:?QUOTO_CORE_PASSWORD must be set}@core-postgres:5432/core
32+
LOGS_PATH: /app/logs/
33+
COLORS: "False"
34+
TZ: ${TZ:-Europe/Kyiv}
35+
# Emoji reach the logs intact only if the streams are UTF-8.
36+
PYTHONIOENCODING: "utf-8"
37+
PYTHONUNBUFFERED: "1"
38+
LANG: C.UTF-8
39+
LC_ALL: C.UTF-8
40+
volumes:
41+
- ./logs:/app/logs
42+
networks: [core_net]
43+
logging:
44+
driver: json-file
45+
options:
46+
max-size: "10m"
47+
max-file: "3"
48+
49+
networks:
50+
core_net:
51+
external: true
52+
name: core_net

0 commit comments

Comments
 (0)