Files
FabledScribe/.forgejo/workflows/ci.yml
T
bvandeusenandClaude Opus 5.5 9bb3b68cc4
CI & Build / Python lint (push) Successful in 2s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m8s
CI & Build / Python tests (push) Successful in 1m53s
CI & Build / Build & push image (push) Successful in 51s
docs: compose files, Dockerfile, CI workflow, README and ci-requirements describe the build, briefly
Comment-only pilot for the comment standard (rule 32, milestone 544).
Issue and rule numbers, dates, run ids and incident stories come out;
the constraint each one guarded stays as a present-tense sentence.

History removed from the files, kept here:
- Dockerfile/CI: installing from uv.lock replaced pip/uv-pip installs that
  ignored the lock (#2194, the mcp 2.0.0 break on 2026-07-28); --locked
  then caught pgvector missing from the lock.
- CI: plugin paths were added after #2198/#2209 reached a live install;
  `integration` joined the build's needs after run 8212 published
  :latest with integration red (#4958); the `[[ ]]` tag step failed
  silently on dash (2a374d9); BUILD_VERSION carried the channel until
  2026-08-31 (#3298).
- jq was removed from the hooks in #4107.
- prod compose: the lenient DB healthcheck followed a runc setns crash
  loop; pgvector arrived with migration 0067 via a PG16->17 dump/restore.

Corrections to match the code: README names the Settings tab
"MCP Access"; ci-requirements says the Dockerfile builds on node:22
and lint covers scripts/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 18:38:42 -04:00

352 lines
14 KiB
YAML

# CI runs first; build only proceeds if all checks pass.
#
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
#
# main is the production line, so :latest tracks main's tip. Every push also
# gets an immutable :<sha>, the rollback point. A release (a v* tag created on
# main in the Forgejo UI) adds the dated :<version>.
#
# A newer push to the same ref cancels the older run; dev and main never cancel
# each other. Pull requests don't trigger runs: branch pushes already gate.
#
# Forgejo Actions doesn't reliably honour `on.push.branches` as a filter, so
# every job repeats the ref check in its `if:`.
#
# Required secrets (repo → Settings → Secrets → Actions):
# REGISTRY_USER — your Forgejo username
# REGISTRY_TOKEN — Forgejo PAT with write:packages scope
name: CI & Build
on:
push:
branches: [dev, main]
tags: ["v*"]
paths:
- "src/**"
- "frontend/**"
- "tests/**"
- "pyproject.toml"
# The lock decides what is installed, so a lock-only change must run CI.
- "uv.lock"
- "alembic/**"
- "alembic.ini"
- "Dockerfile"
# The plugin ships straight from git (via .claude-plugin/marketplace.json),
# not in the image, so a push here is its release. See the `plugin` job.
- "plugin/**"
- ".claude-plugin/**"
- "scripts/check_plugin.py"
- ".forgejo/workflows/ci.yml"
# Manual run from the Forgejo Actions UI, e.g. to rebuild an image.
workflow_dispatch: {}
# A newer push cancels the older run on the same branch. Tag runs are never
# cancelled, so a release build can't be cut off mid-flight.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
# Least-privilege default. Jobs that need more (build pushes to the
# registry) upgrade explicitly.
permissions:
contents: read
env:
REGISTRY: git.fabledsword.com
IMAGE: git.fabledsword.com/bvandeusen/fabledscribe
jobs:
typecheck:
name: TypeScript typecheck
# Gate dev, main, and v* tags; reject any other ref (see header note).
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
- name: Cache npm download cache
uses: actions/cache@v4
# Non-fatal: a cache-backend error just means installing without it.
continue-on-error: true
with:
path: ~/.npm
key: npm-cache-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: npm-cache-
- name: Install dependencies
run: npm ci
working-directory: frontend
- name: Type check
run: npx vue-tsc --noEmit
working-directory: frontend
# Guards the one part of this repo that ships to users without a build step.
# scripts/check_plugin.py says what it checks and what it can't.
plugin:
name: Plugin hooks
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
# Keep this a bare `uses:`: with a `with:` block the action fails to
# extract on act_runner ("Cannot find module .../dist/index.js"). The
# version check diffs two trees, so a depth-1 fetch of main is enough.
- uses: actions/checkout@v6
# Per-job rather than in the image: Scribe is shellcheck's only consumer.
# Install nothing else here; the hooks use only POSIX tools, and the
# smoke test runs on a bare image to prove it.
- name: Install shell tooling
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends shellcheck
# On main the comparison would be against itself, so only the syntax and
# pattern checks mean anything there.
- name: Check plugin hooks and manifest
run: |
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
python3 scripts/check_plugin.py --no-version
else
git fetch --no-tags --depth=1 origin main:refs/remotes/origin/main
python3 scripts/check_plugin.py
fi
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
# ruff is pre-installed in the ci-python image.
- name: Lint
run: ruff check src/ scripts/
# Checks the frontend's CSS against the committed, generated theme.css.
# Fails only on an unresolvable var() reference; hard-coded literals are
# reported, not gated.
- name: Design token check
run: python3 scripts/check_design_tokens.py --report-literals
# Reports elements whose classes have modifier rules but no base rule,
# which vue-tsc can't see. Not gated: a bare wrapper is legitimate, so
# the signal is the count growing.
- name: Dangling style check
run: python3 scripts/check_dangling_styles.py
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v6
- name: Cache uv packages
uses: actions/cache@v4
with:
path: ~/.cache/uv
# Keyed on the lock, which decides the installed set.
key: uv-${{ hashFiles('uv.lock') }}
restore-keys: uv-
# Installs exactly what uv.lock pins, so green means these exact versions
# passed. `--locked` fails if the lock is stale against pyproject, so a
# dependency change needs a deliberate `uv lock`.
- name: Install locked dependencies
env:
UV_PROJECT_ENVIRONMENT: /opt/venv
run: uv sync --locked --extra dev
# The hook execution tests run on a bare image (no jq), like a user's
# machine might be.
- name: Run tests
# Integration tests (real Postgres) run in the `integration` job below.
run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration"
# Real Postgres, built by the real migrations: covers the async SQLAlchemy
# paths unit stubs can't reach.
#
# Service hostnames aren't routable on this runner, so the step finds the
# Postgres container's bridge IP. The job has no `name:` and a separator-free
# key because act_runner names service containers after it.
integration:
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
env:
# Read at import time. DATABASE_URL is built from the discovered IP below.
SECRET_KEY: ci_integration_placeholder
services:
postgres:
# Same image as the shipped compose files: Postgres 17 with pgvector.
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: scribe
POSTGRES_PASSWORD: ci_integration
POSTGRES_DB: scribe_test
options: >-
--health-cmd "pg_isready -U scribe"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v6
# Same locked install as the unit lane, so both test the same versions.
- name: Install locked dependencies
env:
UV_PROJECT_ENVIRONMENT: /opt/venv
run: uv sync --locked --extra dev
# Prints two facts about the runner that the steps below depend on.
# Every command is guarded so the diagnostic can't fail the lane.
- name: Runner facts (shell, service hostnames)
run: |
echo "--- which shell runs a run: step ---"
readlink -f /bin/sh || echo "/bin/sh: not a symlink"
ps -p $$ -o comm= || true
echo "--- is a service reachable by its hostname? ---"
getent hosts postgres \
|| echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required"
- name: Integration suite (resolve service IP, migrate, test)
run: |
set -eux
echo "=== container landscape (diagnostic for the name filter) ==="
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
# Only this job's services: the docker daemon is shared, so a job-name
# filter can match another repo's Postgres. act names a task's containers
# GITEA-ACTIONS-TASK-<n>-...; read <n> from our own container (its id is in
# the /etc/hostname bind mount) and require exactly one match.
SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true)
SELF=${SELF:-$(hostname)}
TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-')
test -n "$TASK"
PG=$(docker ps --filter "name=$TASK" --filter "ancestor=pgvector/pgvector:pg17" -q)
test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
export DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test"
# Wait for Postgres. The run: shell is dash (no bash /dev/tcp), so use Python.
/opt/venv/bin/python - "$PG_IP" <<'PY'
import socket, sys, time
for _ in range(30):
try:
socket.create_connection((sys.argv[1], 5432), timeout=2).close()
break
except OSError:
time.sleep(1)
else:
sys.exit("postgres did not become reachable")
PY
# Real migrations build the schema; the maintenance tests then run
# VACUUM (ANALYZE) and read pg_stat_user_tables against it.
/opt/venv/bin/alembic upgrade head
/opt/venv/bin/python -m pytest tests/ -v -m integration
build:
name: Build & push image
# `plugin` is not in needs: the plugin ships from git, not in the image, so
# holding the image back wouldn't stop a bad hook. A red `plugin` job still
# turns the run red. `integration` is: the image carries the migrations and
# queries that lane tests.
needs: [typecheck, lint, test, integration]
# dev → :dev, main → :latest, tag → :latest + :<version>; all get :<sha>.
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
with:
# Full history for the version derivation. Today it needs only HEAD's
# commit time, but a shallow clone fails silently (a too-low value) if
# the derivation ever reads further back.
fetch-depth: 0
- name: Generate image tags and version
id: tags
# POSIX `case`, not bash `[[ ]]`: the run: shell is dash, where `[[`
# fails silently and only the :<sha> tag would be pushed.
run: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
# Three values kept separate; the Dockerfile describes each.
# 1. Ordering key: build time in minutes since 2020-01-01, so it only
# goes up. Commit time would go down when an older commit is
# rebuilt; a commit count isn't monotonic across branches.
BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 ))
# 2. Name: commit time, so the same source reads the same on every lane.
COMMIT_TS=$(git log --format=%ct -1 HEAD)
BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M)
# 3. Channel: its own value, never part of the name.
CHANNEL="dev"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
;;
refs/heads/main)
# main is the production line, so it publishes :latest.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
CHANNEL="stable"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
CHANNEL="stable"
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT
echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT
echo "channel=$CHANNEL" >> $GITHUB_OUTPUT
- name: Free disk space
# Prunes unused containers and images, and caps the BuildKit cache at
# 5 GB so the package caches survive but old layers don't pile up.
run: |
docker system prune -af || true
docker builder prune --keep-storage 5g -f || true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to Forgejo registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
push: true
provenance: false
tags: ${{ steps.tags.outputs.value }}
# The commit lets the image's self-report be checked against :<sha>.
build-args: |
BUILD_VERSION=${{ steps.tags.outputs.build_name }}
BUILD_KEY=${{ steps.tags.outputs.build_key }}
BUILD_CHANNEL=${{ steps.tags.outputs.channel }}
BUILD_COMMIT=${{ github.sha }}
# Layer cache kept in the registry, so a build starts warm even after
# the runner prunes. `mode=max` caches intermediate layers too.
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true