CI & Build / Python lint (push) Failing after 3s
CI & Build / Plugin hooks (push) Successful in 9s
CI & Build / TypeScript typecheck (push) Successful in 23s
CI & Build / integration (push) Successful in 27s
CI & Build / Python tests (push) Canceled after 38s
CI & Build / Build & push image (push) Canceled after 0s
Operator reported four things looking wrong. Two were the same bug, and it is
not a design drift — it is deleted CSS.
Removing a rule from a scoped stylesheet leaves its modifiers behind. The
selector still exists, so nothing reads as unused, and the element renders with
no base styling at all:
.btn-workspace base gone, :hover survived — the Workspace link rendered
as raw browser blue, underlined
.milestone-header base gone, .clickable and :hover survived. Every child is
written for a flex ROW (.ms-name { flex: 1 }, the progress
track, .ms-pct), so without the parent they stacked and a
one-line milestone became five. That is the "projects
section uses space poorly" — a deletion, not a redesign.
.milestone-group no rule at all; the card around each milestone
.ds-header only its h1 descendant survived
vue-tsc cannot see any of it. A dead style typechecks perfectly.
scripts/check_dangling_styles.py finds the shape: an element whose every static
class has no base rule anywhere, while at least one carries modifier rules. It
reports 11 more. Reported and not gated, because a genuinely bare wrapper is
legitimate — the signal is the count growing. Runs in the lint lane, stdlib
only, and knows no class name or convention (rule #115).
Also from the same report:
- The header pill bar was `position: absolute; left: 50%`, so it did not
participate in layout: out of room, it OVERLAPPED the brand and the utility
cluster instead of pushing them. A sixth link reached that at ~1270px, an
ordinary window. Now `1fr auto 1fr` — a 1fr track has an auto minimum, so
neither side can be squeezed under its content and the two stay equal, which
is what keeps the bar centred in the viewport rather than in the leftover
space. Overflow becomes the header growing, not two things sharing pixels.
- The token preview put its checkerboard on the whole specimen stage, so every
swatch sat in a frame of checks and the pattern read as the loudest thing on
the page. The checks now sit UNDER the colour as a second background layer:
an opaque value hides them, a 15% tint shows exactly as much as it should.
Text-bearing specimens lose the box entirely, and name/value/purpose are one
line each with the full text on hover — they wrapped freely before, so a card
was two lines tall or five depending on how long its color-mix() happened to
be, and the grid had no rhythm.
- .btn-cta joins the shared button family: the gradient-and-glow brand moment
the system carries tokens for, which had been living in one view's scoped
block. That is what made it deletable. The header actions are now one size
and one family instead of four sizes and two.
- The shared button shape gained inline-flex + gap, so a button carrying an
icon centres it without each caller rebuilding the row.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
387 lines
17 KiB
YAML
387 lines
17 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:
|
|
# Bare `uses:`, no `with:` block. Adding one made this action fail to
|
|
# extract on the act_runner ("Cannot find module .../dist/index.js") while
|
|
# every bare checkout in the same run succeeded — see run 3027. Nothing
|
|
# here needs `fetch-depth: 0` anyway: the version check diffs two trees,
|
|
# and a tree diff needs both trees, not a common ancestor. A depth-1 fetch
|
|
# of main's tip is enough, and cheaper.
|
|
- uses: actions/checkout@v6
|
|
|
|
# Per-job, not in the image, per CI-runner's docs/process.md: "If only one
|
|
# project needs the dep, prefer that project installing it per-job in
|
|
# their workflow — at least until a second consumer arrives." Scribe is
|
|
# the only consumer today. Promotion into ci-python is filed as an issue
|
|
# on CI-runner rather than assumed here.
|
|
#
|
|
# jq is not optional for the smoke test: every hook exits at line 1
|
|
# without it, so the check would pass while exercising nothing.
|
|
- name: Install shell tooling
|
|
run: |
|
|
apt-get update -qq
|
|
apt-get install -y -qq --no-install-recommends jq 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 — no install
|
|
# step needed, lint runs in ~2s.
|
|
- name: Lint
|
|
run: ruff check src/ scripts/
|
|
|
|
# Design tokens: does the frontend's CSS agree with the stylesheet the
|
|
# design system generates? Fails only on an unresolvable var() reference —
|
|
# that count is at zero, so this is a ratchet rather than a backlog. The
|
|
# literal findings are printed, not gated; hundreds exist and a
|
|
# permanently-red job is one nobody reads.
|
|
#
|
|
# Stdlib only, no install, no network: the source of truth is theme.css,
|
|
# which is generated from the design system and committed.
|
|
- name: Design token check
|
|
run: python3 scripts/check_design_tokens.py --report-literals
|
|
|
|
# Dangling styles: an element whose classes have only modifier rules and
|
|
# no base — a deleted CSS rule that left its `:hover` behind. Two shipped
|
|
# this way (a link rendering as raw browser blue, a flex row whose parent
|
|
# was gone so every child stacked). Neither is visible to vue-tsc; a dead
|
|
# style typechecks perfectly. Reported, 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, 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
|