CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 12s
CI & Build / TypeScript typecheck (push) Successful in 52s
CI & Build / integration (push) Successful in 1m1s
CI & Build / Python tests (push) Successful in 1m55s
CI & Build / Build & push image (push) Successful in 36s
Step 1 of the Scribe -> Grimoire rename: every capitalised "Scribe" in prose
becomes "Grimoire" ("Fabled Scribe" -> "Fabled Grimoire") across the UI,
the PWA manifest and page title, the MCP instructions and tool docstrings,
hook and skill text, docs and README, with the tests that assert on that
prose moving with it. Old migrations keep their wording.
Identifiers are untouched here: the package, plugin name, MCP server key,
hook file names, env vars, the .scribe marker and FabledScribe repo/image
names move in steps 2 and 3, along with prose that quotes them.
The sweep matched "Scribe" only as a whole word, so describe/subscribe and
friends are untouched. The longer name took the MCP instructions one
character over their 1,600 budget; two phrases are shortened to fit.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
350 lines
14 KiB
YAML
350 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: Grimoire 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
|
|
|
|
# No disk-space prune step: the runner's Docker daemon is shared, and
|
|
# `docker system prune -af` here deleted images other repos' jobs had
|
|
# just pulled and were about to start (CI-runner #16). The runner host
|
|
# prunes nightly instead.
|
|
|
|
- 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
|