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
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>
352 lines
14 KiB
YAML
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
|