CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 22s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 1m57s
Build images / build-ml (push) Successful in 2m38s
CI / integration (push) Successful in 3m50s
The guard asked whether a packaged extension file changed without the version moving. Since step 4 nobody moves the version by hand, so it was checking a fact that had stopped existing — and it was not merely dead weight: it would have failed the lane on every real extension change, demanding a bump that decides nothing. Removed rather than left running beside the new mechanism (rule 22). What replaces it is thinner and true. The extension-version lane now asserts the derivation resolves on this commit, that the derived value is the plain dotted-numeric shape AMO accepts, and that MAJOR.MINOR agrees between manifest.json and package.json. MAJOR.MINOR is the one part still hand-set, and packaging.sh reads it from manifest.json ALONE, so a divergence ships a version package.json disagrees with. The lane keeps fetch-depth: 0 — checking that the derivation survives a real checkout is half its remaining value. Deliberately not checked there: that the derived value beats what is already signed. That guard belongs in build.yml, where it compares against the real ext-* releases. Comparing against origin/main in a lane would be wrong, because dev legitimately derives a LOWER value whenever main is ahead on the extension, and a lane that fails for being behind is a lane people learn to ignore. packaging.sh is down to two consumers from three. version.spec.js's "ci.yml derives its pathspec" test would have gone red on that, so it is rewritten to assert the property rather than the consumer: no workflow inlines an :(exclude)extension/ literal, across all three. That keeps the #2397 anti-regression value while surviving consumers coming and going. A second test pins build.yml to packaging.sh version and fails if it goes back to grepping the committed value — which is not a style regression but the #3092 bug itself. build.yml joins extension.yml's trigger paths, since the suite now asserts against it. The lockstep test narrows from the whole version string to MAJOR.MINOR. The committed patch numbers are inert now; asserting on them would fail for a difference that changes nothing. Docs. extension/README.md's Release section described extension.yml signing on main and committing the XPI into frontend/public/ — untrue since 2026-05-25, and it told the reader to hand-bump both files, which is now exactly the wrong instruction. Rewritten, with a Versioning section that says plainly that editing the patch number does nothing and why the key is commit time rather than a count. ci-requirements.md drops the third packaging.sh consumer and names every job that needs full history. Root README no longer claims the extension is signed on main only.
254 lines
13 KiB
YAML
254 lines
13 KiB
YAML
name: CI
|
|
|
|
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
|
|
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
|
|
# - extension-version: the derived version resolves and MAJOR.MINOR agrees.
|
|
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
|
|
# - frontend-build: vitest unit + vite build.
|
|
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
|
|
|
|
on:
|
|
push:
|
|
branches: [dev, main]
|
|
# Renovate opens PRs from `renovate/*` branches into `dev`. Those branches
|
|
# never push to dev/main, so the push trigger above gives them NO pre-merge
|
|
# CI — a bump could only be validated after it was already merged. This
|
|
# pull_request trigger (base `dev` only) validates Renovate PRs before merge.
|
|
# It deliberately does NOT fire on dev→main PRs (base `main`), which still
|
|
# rely on the dev push run — so no duplicate runs. FC has no fork PRs
|
|
# (single-operator Forgejo repo), so secrets-on-PR is not a concern.
|
|
pull_request:
|
|
branches: [dev]
|
|
|
|
jobs:
|
|
# Fast-fail lint lane. ruff is pre-installed in the ci-python image, so
|
|
# this runs with NO dependency install and surfaces the most common bounce
|
|
# class (lint: I001 / UP037 / ASYNC109 / W293 …) in seconds — instead of
|
|
# after the backend job's ~30-60s wheel install. ruff is static analysis,
|
|
# so no DB/secret env is needed.
|
|
lint:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: Ruff lint
|
|
# agent/ included so the GPU-agent is linted before its image is built
|
|
# (build.yml only `docker build`s it — this is where it gets checked).
|
|
run: ruff check backend/ tests/ alembic/ agent/
|
|
- name: Agent syntax check
|
|
# The agent's runtime deps (torch/transformers/ultralytics) aren't in the
|
|
# CI image, so we can't import it — but compileall parses every module,
|
|
# catching syntax errors before the image build.
|
|
run: python -m compileall -q agent/fc_agent
|
|
|
|
# The extension version is DERIVED, not hand-maintained (milestone 271 step
|
|
# 4): build.yml computes it from the commit TIME of the newest packaged
|
|
# extension change and stamps it into manifest.json / package.json at build
|
|
# time. The guard that used to live here — "packaged files changed but nobody
|
|
# bumped the version" — was therefore checking a fact that had stopped
|
|
# existing. Worse than useless: it would have failed this lane on every real
|
|
# extension change, demanding a bump that decides nothing. Retired 2026-08-27
|
|
# rather than left running beside the new mechanism (rule 22).
|
|
#
|
|
# Two things are still worth asserting, and this is the only lane that can:
|
|
# the extension.yml suite runs on node:24-slim, which is exactly why
|
|
# version.spec.js sticks to packaging.sh's git-free subcommands.
|
|
# 1. the derivation actually resolves on this commit
|
|
# 2. MAJOR.MINOR agrees between the two files — the one part still hand-set,
|
|
# and packaging.sh reads it from manifest.json ALONE, so a divergence
|
|
# ships a version package.json disagrees with
|
|
#
|
|
# Deliberately NOT checked here: that the derived value beats what has already
|
|
# been signed. That guard belongs in build.yml, where it compares against the
|
|
# real ext-* releases. Comparing against origin/main here would be wrong —
|
|
# dev legitimately derives a LOWER value whenever main is ahead on the
|
|
# extension, and a lane that fails for being behind is a lane people learn to
|
|
# ignore.
|
|
extension-version:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
# The derivation needs real history: a depth-1 clone sees one commit
|
|
# and produces a wrong, too-low value RATHER THAN FAILING. Checking
|
|
# that here is half the point of the lane.
|
|
fetch-depth: 0
|
|
- name: Extension version derives cleanly
|
|
run: |
|
|
set -eu
|
|
# busybox sh on the act_runner — no bashisms (family rule).
|
|
VERSION=$(sh extension/scripts/packaging.sh version)
|
|
echo "derived: $VERSION"
|
|
# The shape AMO accepts, and the shape build.yml will stamp.
|
|
if ! echo "$VERSION" | grep -qE '^[0-9]+(\.[0-9]+)*$'; then
|
|
echo "ERROR: derived version '$VERSION' is not plain dotted-numeric."
|
|
echo "AMO would reject it, and build.yml stamps it verbatim."
|
|
exit 1
|
|
fi
|
|
mm() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+\.[0-9]+).*/\1/'; }
|
|
MAN=$(mm extension/manifest.json)
|
|
PKG=$(mm extension/package.json)
|
|
test -n "$MAN" || { echo "ERROR: no parseable version in extension/manifest.json"; exit 1; }
|
|
test -n "$PKG" || { echo "ERROR: no parseable version in extension/package.json"; exit 1; }
|
|
if [ "$MAN" != "$PKG" ]; then
|
|
echo "ERROR: MAJOR.MINOR disagrees between the two files."
|
|
echo " extension/manifest.json = $MAN <- packaging.sh reads MAJOR.MINOR from here"
|
|
echo " extension/package.json = $PKG"
|
|
echo "Only MAJOR.MINOR is hand-set. The patch component is derived from"
|
|
echo "commit time and overwritten at build time, so the committed patch"
|
|
echo "numbers are inert — but MAJOR.MINOR still ships. Set both the same."
|
|
exit 1
|
|
fi
|
|
echo "OK: MAJOR.MINOR $MAN, derived version $VERSION"
|
|
|
|
backend-lint-and-test:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
env:
|
|
# DB_PASSWORD and SECRET_KEY are required by config.py at import time
|
|
# even though unit tests don't actually touch the DB or use the secret.
|
|
DB_PASSWORD: ci_unit_test_placeholder
|
|
SECRET_KEY: ci_unit_test_placeholder
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
# Cache step removed 2026-05-26: act_runner's cache backend has been
|
|
# broken on this homelab runner since 2026-05-15 (first as request-
|
|
# timeout warnings, then as hard "Cannot find module .../dist/restore/
|
|
# index.js" failures that tank the whole job). The cache step targeted
|
|
# ~/.cache/pip but the install below uses `uv pip install` primarily,
|
|
# whose own cache lives at ~/.cache/uv — so the cache step's real
|
|
# benefit was marginal even when working. Cost of removal: ~30s of
|
|
# wheel downloads per job. Future re-enable: mount ~/.cache/uv as a
|
|
# docker volume at the runner level (skips actions/cache entirely),
|
|
# or fix the runner-side cache backend (clear /var/run/act/actions/*,
|
|
# pin act_runner version, etc.).
|
|
|
|
- name: Install Python deps
|
|
# ruff is pre-installed in the ci-python image (see CI-Runner/CI-python/
|
|
# Dockerfile's RUFF_VERSION). Per FabledRulebook ci-runners.md, toolchain
|
|
# versions live on the runner image, not here.
|
|
# uv: 5-10x faster wheel resolve than pip for cold caches.
|
|
# Falls back to pip install on uv-missing runners (older images).
|
|
run: |
|
|
if command -v uv >/dev/null 2>&1; then
|
|
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
|
else
|
|
pip install -r requirements.txt pytest pytest-asyncio
|
|
fi
|
|
|
|
# Ruff moved to the dedicated fast `lint` job above (fails in seconds,
|
|
# no dep install). This job is now unit tests only.
|
|
- name: Pytest (unit only — integration runs in the integration job)
|
|
run: pytest tests/ -v -m "not integration"
|
|
|
|
frontend-build:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
defaults:
|
|
run:
|
|
working-directory: frontend
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
# No package-lock.json is tracked yet (we don't run npm locally per
|
|
# feedback-no-local-runs). Using `npm install` instead of `npm ci`.
|
|
# If we want strict lockfile-based reproducibility later, commit a
|
|
# package-lock.json and flip this back to `npm ci`.
|
|
- run: npm install --no-audit --no-fund
|
|
# No type-check step: the frontend is pure JS (no .ts files, no JSDoc),
|
|
# so a type-checker has nothing to do. The vue-tsc devDep + its `check`
|
|
# script were dropped 2026-07-11 rather than bumped to v3. If we add
|
|
# TS/JSDoc later, re-add a tsconfig.json + vue-tsc + a type-check step.
|
|
- run: npm run test:unit
|
|
- run: npm run build
|
|
|
|
# Single integration job — collapsed from a 3-way shard split on 2026-06-04.
|
|
# The shards existed to parallelize ~8.5min of integration tests; once the
|
|
# throwaway Postgres runs with fsync OFF (the durability step below) the whole
|
|
# suite runs in ~45s, so the split only triplicated the ~2min fixed overhead
|
|
# (container + `uv pip install` + `alembic upgrade head`) and burned 3 of 6
|
|
# runner slots for no wall-clock gain. One job now: spin up once, install
|
|
# once, migrate once, run every integration test.
|
|
#
|
|
# The docker-ps filter scopes to THIS job's own Postgres/Redis service
|
|
# containers by job name. act_runner strips underscores from job names when
|
|
# labelling containers (`int_api` matched nothing on 2026-05-25), so the name
|
|
# stays separator-free (`integration`). The step prints `docker ps -a` first
|
|
# so a future naming-convention shift surfaces in the log without a
|
|
# guess-and-push cycle.
|
|
#
|
|
# Pre-baking requirements.txt into ci-python:3.14 is intentionally NOT done —
|
|
# per ci-requirements.md, FC is the only Python consumer of that image and the
|
|
# CI-Runner "add deps to image when used by >1 project" rule keeps it per-job.
|
|
integration:
|
|
runs-on: python-ci
|
|
container:
|
|
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
|
env:
|
|
DB_USER: fabledcurator
|
|
DB_PASSWORD: ci_integration
|
|
DB_PORT: "5432"
|
|
DB_NAME: fabledcurator_test
|
|
SECRET_KEY: ci_integration_placeholder
|
|
services:
|
|
postgres:
|
|
image: pgvector/pgvector:pg16
|
|
env:
|
|
POSTGRES_USER: fabledcurator
|
|
POSTGRES_PASSWORD: ci_integration
|
|
POSTGRES_DB: fabledcurator_test
|
|
options: >-
|
|
--health-cmd "pg_isready -U fabledcurator"
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 10
|
|
redis:
|
|
image: redis:7-alpine
|
|
options: >-
|
|
--health-cmd "redis-cli ping"
|
|
--health-interval 10s
|
|
--health-timeout 5s
|
|
--health-retries 10
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- name: Integration suite (resolve service IPs, migrate, test)
|
|
run: |
|
|
set -eux
|
|
echo "=== container landscape (diagnostic for filter scoping) ==="
|
|
docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}'
|
|
echo "=== end landscape ==="
|
|
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
|
|
RD=$(docker ps --filter "name=integration" --filter "ancestor=redis:7-alpine" -q | head -n1)
|
|
test -n "$PG" && test -n "$RD"
|
|
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
|
|
RD_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$RD")
|
|
test -n "$PG_IP" && test -n "$RD_IP"
|
|
export DB_HOST="$PG_IP"
|
|
export CELERY_BROKER_URL="redis://$RD_IP:6379/0"
|
|
export CELERY_RESULT_BACKEND="redis://$RD_IP:6379/0"
|
|
for i in $(seq 1 60); do
|
|
(echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break
|
|
sleep 2
|
|
done
|
|
if command -v uv >/dev/null 2>&1; then
|
|
uv pip install --system -r requirements.txt pytest pytest-asyncio
|
|
else
|
|
pip install -r requirements.txt pytest pytest-asyncio
|
|
fi
|
|
# Relax durability on the throwaway CI Postgres so the per-test
|
|
# TRUNCATE's commit-fsync — the integration teardown's dominant cost
|
|
# (~1.5-2s/test, which collapsed the suite from ~13min to ~45s) — is
|
|
# skipped. fsync/full_page_writes are sighup GUCs and synchronous_commit
|
|
# is user-context, so ALTER SYSTEM + pg_reload_conf() applies them with
|
|
# NO restart. Ephemeral DB ⇒ fsync-off is safe. Non-fatal so a perms
|
|
# surprise can't red the job; fabledcurator is the postgres image's
|
|
# bootstrap superuser.
|
|
python -c "import os,psycopg; c=psycopg.connect(host=os.environ['DB_HOST'],port=5432,user=os.environ['DB_USER'],password=os.environ['DB_PASSWORD'],dbname=os.environ['DB_NAME'],autocommit=True); [c.execute(q) for q in ('ALTER SYSTEM SET fsync=off','ALTER SYSTEM SET synchronous_commit=off','ALTER SYSTEM SET full_page_writes=off','SELECT pg_reload_conf()')]; c.close()" || echo 'WARN: durability GUC relax failed (continuing)'
|
|
alembic upgrade head
|
|
pytest tests/ -v -m integration --durations=15
|