51e5c22818
CI & Build / Plugin hooks (push) Failing after 2s
CI & Build / Python lint (push) Successful in 8s
CI & Build / integration (push) Successful in 31s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 55s
CI & Build / Build & push image (push) Successful in 20s
`plugin/` is not built into the image; installs fetch it from this repo via .claude-plugin/marketplace.json, so a push IS the release. It was absent from the workflow's `paths:` filter entirely, meaning plugin changes ran no CI at all. Two separate defects reached a live install through that gap: #2198 — all four hook scripts inert (lowercase userConfig env vars, line-oriented `jq -rR`, line-oriented `cut -c`) #2209 — the fix for #2198 couldn't reach an install because the manifest version wasn't bumped, so the installer never refreshed its cache Adds `plugin/**` + `.claude-plugin/**` to `paths:` and a `plugin` job running scripts/check_plugin.py: 1. `bash -n` on every hook. 2. The three known-bad patterns from #2198. Verified by replay against c569cdd^ — all three are caught. Narrow by design; see below. 3. Shipped plugin content differs from origin/main => the manifest version must differ too. Stated against the base branch, not per-commit, so a batch needs one bump rather than one per commit. Replayed againstc569cdd: correctly fails. The checker found a real outstanding bug on its first run: the `cut -c1-2000` prompt cap in scribe_autoinject.sh was still line-oriented. Only the prior-art hook's copy got fixed inc569cdd. Now `head -c`. It then failed on this very commit for a missing version bump, which is the third time that rule has mattered and the first time something other than memory enforced it. Manifest bumped to 0.1.20. WHAT THIS DOESN'T COVER, and why. shellcheck is the right tool for check 2 and is NOT in ci-python; nor is jq, which every hook requires and silently bails without — so a "runs and stays silent" smoke test would pass vacuously today and prove nothing. Both need those two packages added to the CI image in the CI-runner repo, which is a separate change to a separate repo (rule #5: the toolchain comes from the image, not from apt-get at job start). Verified against CI-runner's Dockerfile and scripts/install-common.sh rather than assumed (rule #37). `plugin` is not in the build job's `needs`: the plugin doesn't ship in the image, and blocking the build wouldn't un-publish a bad hook — the push already did. A failed job still reddens the run. Closes #2204 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
353 lines
15 KiB
YAML
353 lines
15 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>
|
|
#
|
|
# Both dev and main are gated AND built. dev pushes move :dev; main pushes move
|
|
# :latest — main IS the production line, so :latest tracks main's tip and there
|
|
# is no separate :main tag. Every push also gets an immutable :<sha> (the
|
|
# rollback point). A v* release tag additionally publishes the dated :<version>;
|
|
# since main already moved :latest, the release tag's distinct job is that
|
|
# :<version> marker (it refreshes :latest too, harmlessly).
|
|
#
|
|
# Successive pushes to the SAME ref supersede each other (see concurrency
|
|
# below), so rapid pushes don't stack identical work; dev and main runs are
|
|
# independent refs and never cancel one another.
|
|
#
|
|
# To cut a release:
|
|
# Create a release via the Forgejo UI on main with a v* tag name.
|
|
# The tag push triggers this workflow; build job pushes :latest + :<version>.
|
|
#
|
|
# PRs aren't triggered on purpose — this is a solo dev→main flow, so
|
|
# gating on branch push is already enough.
|
|
#
|
|
# NOTE on the `if:` guards below: Forgejo Actions does not consistently
|
|
# honor `on.push.branches` as a filter, so every job repeats the ref check
|
|
# explicitly — permitting dev, main, and v* tags, rejecting anything else.
|
|
#
|
|
# 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 now determines what gets installed, so a lock-only change has
|
|
# to trigger a run — otherwise a dependency bump lands untested.
|
|
- "uv.lock"
|
|
- "alembic/**"
|
|
- "alembic.ini"
|
|
- "Dockerfile"
|
|
- "assets/**"
|
|
- "fable-mcp/**"
|
|
# The plugin ships straight from this repo — installs fetch it via
|
|
# .claude-plugin/marketplace.json, NOT from the image. So a push here is
|
|
# the release, with no build step in between. Omitting these paths meant
|
|
# plugin changes triggered no workflow at all, which is how #2198's three
|
|
# broken hooks and then #2209's missing version bump both reached a live
|
|
# install. See the `plugin` job below.
|
|
- "plugin/**"
|
|
- ".claude-plugin/**"
|
|
- "scripts/check_plugin.py"
|
|
- ".forgejo/workflows/ci.yml"
|
|
# Manual trigger from the Forgejo Actions UI. Useful when an image has
|
|
# been built but the deployment didn't pick it up, or when re-running
|
|
# against the same source produces different upstream behaviour
|
|
# (e.g. a transient HF download flake during the voice-bundle step).
|
|
workflow_dispatch: {}
|
|
|
|
# Cancel older runs on the same branch when a newer push lands. Tag runs
|
|
# get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and
|
|
# are never cancelled, so a release build can't kill itself 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 transient cache-backend hiccup must NOT fail the whole
|
|
# typecheck job (it was skipping install + type check and reporting red
|
|
# on backend-only pushes — see issue task #828). On cache miss/error the
|
|
# job just installs without the cache.
|
|
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.
|
|
# See scripts/check_plugin.py for what it checks and, as importantly, what it
|
|
# can't check yet (shellcheck and jq are absent from ci-python).
|
|
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:
|
|
- uses: actions/checkout@v6
|
|
with:
|
|
# The version-bump check diffs shipped plugin content against
|
|
# origin/main, so it needs history — a shallow clone can't resolve it
|
|
# and the check would fail loudly rather than pass blind.
|
|
fetch-depth: 0
|
|
|
|
# On main the comparison is against itself, so only the syntax and
|
|
# pattern checks are meaningful 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 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 — no install
|
|
# step needed, lint runs in ~2s.
|
|
- name: Lint
|
|
run: ruff check src/
|
|
|
|
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, not pyproject: the lock is what determines the
|
|
# installed set now, and a pyproject edit that doesn't change
|
|
# resolution shouldn't throw the cache away.
|
|
key: uv-${{ hashFiles('uv.lock') }}
|
|
restore-keys: uv-
|
|
|
|
# Installs exactly what uv.lock pins, and resolves nothing itself.
|
|
#
|
|
# This replaced `uv pip install -e ".[dev]"`, which resolved from the
|
|
# pyproject constraints and ignored the lock entirely. Every dependency
|
|
# floated: on 2026-07-28 mcp 2.0.0 shipped mid-session and turned `main`
|
|
# red with no repo change (issue #2194). Green CI has to mean "these exact
|
|
# versions passed", or it isn't evidence of anything.
|
|
#
|
|
# `--locked` also FAILS when uv.lock is stale against pyproject, so a
|
|
# dependency edit has to go through a deliberate `uv lock` — it can't
|
|
# arrive on its own. That check earned its place immediately: it caught
|
|
# that the lock had been missing `pgvector` entirely (added to pyproject,
|
|
# never re-locked), which the old install path had been silently papering
|
|
# over by resolving from pyproject instead.
|
|
- name: Install locked dependencies
|
|
env:
|
|
UV_PROJECT_ENVIRONMENT: /opt/venv
|
|
run: uv sync --locked --extra dev
|
|
|
|
- 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 lane (family rule 6). Exercises the async SQLAlchemy connection
|
|
# path the unit stubs can't reach — the un-awaited execution_options regression
|
|
# that made every VACUUM report 0/6 lived here. Like `test`, it runs for
|
|
# visibility and does NOT gate the build.
|
|
#
|
|
# Job key stays separator-free ("integration"): act_runner derives the service-
|
|
# container name from the (truncated) job display name and the discovery step
|
|
# filters `docker ps` by it. Service hostnames aren't routable on this runner,
|
|
# so the step resolves the Postgres container's bridge IP. No `name:` on purpose.
|
|
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:
|
|
# Config + the module engine read these at import time. DATABASE_URL itself
|
|
# is built from the discovered service IP in the run step.
|
|
SECRET_KEY: ci_integration_placeholder
|
|
services:
|
|
postgres:
|
|
# pgvector image so `alembic upgrade head` can run migration 0067
|
|
# (CREATE EXTENSION vector). PG17 — matches the prod/quickstart image.
|
|
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 — the two must agree on versions,
|
|
# or "unit green, integration red" stops being a signal about the code.
|
|
- name: Install locked dependencies
|
|
env:
|
|
UV_PROJECT_ENVIRONMENT: /opt/venv
|
|
run: uv sync --locked --extra dev
|
|
- 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}}'
|
|
PG=$(docker ps --filter "name=integration" --filter "ancestor=pgvector/pgvector:pg17" -q | head -n1)
|
|
test -n "$PG"
|
|
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 to accept connections (busybox sh — the runner
|
|
# default — has 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 deliberately NOT in needs. The plugin isn't in the image —
|
|
# installs fetch it from git — so gating the server image on a hook lint
|
|
# would couple two things that don't ship together, and blocking the build
|
|
# wouldn't un-publish a bad hook anyway: the push already did that. A failed
|
|
# `plugin` job still turns the whole run red, which is the signal that
|
|
# matters.
|
|
needs: [typecheck, lint, test]
|
|
# Build on dev, main, and v* tag pushes. dev → :dev, main → :latest,
|
|
# tag → :latest + :<version>; every build also gets an immutable :<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
|
|
|
|
- name: Generate image tags and version
|
|
id: tags
|
|
# POSIX `case` instead of bash `[[ ]]` because act_runner invokes
|
|
# `sh -e` (dash on the ci-python:3.14 image, which has no bash on
|
|
# the default PATH for /bin/sh). Previous `[[ ]]` form failed
|
|
# silently — only the SHA tag got appended, so :dev / :latest
|
|
# never updated in the registry and the deployed stack kept
|
|
# pulling stale images. Verified via `[[: not found` lines in
|
|
# the runner log on commit 2a374d9.
|
|
run: |
|
|
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
|
|
BUILD_VERSION="dev"
|
|
case "${{ github.ref }}" in
|
|
refs/heads/dev)
|
|
TAGS="$TAGS,${{ env.IMAGE }}:dev"
|
|
;;
|
|
refs/heads/main)
|
|
# main IS the production line: publish :latest (plus the :<sha>
|
|
# set above). No separate :main tag.
|
|
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
|
BUILD_VERSION="main"
|
|
;;
|
|
refs/tags/*)
|
|
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
|
BUILD_VERSION="${{ github.ref_name }}"
|
|
;;
|
|
esac
|
|
echo "value=$TAGS" >> $GITHUB_OUTPUT
|
|
echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT
|
|
|
|
- name: Free disk space
|
|
# Self-hosted runner housekeeping. Two-step cleanup:
|
|
# 1. Prune dangling containers/images globally (stops the runner
|
|
# from accumulating cruft from past failed builds).
|
|
# 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip
|
|
# mount cache survives but old intermediate layers don't
|
|
# accumulate indefinitely.
|
|
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 }}
|
|
build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }}
|
|
# Registry-backed layer cache. Pull from :cache to prime
|
|
# BuildKit, push updated layers back to :cache so the next
|
|
# build starts warm even if the runner's local cache was
|
|
# pruned. `mode=max` exports all intermediate layers, not
|
|
# just the final image, which is what gives the ~80% speedup.
|
|
cache-from: type=registry,ref=${{ env.IMAGE }}:cache
|
|
cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true
|