Milestone 334 step 2. The set of files that reach a plugin install lived in
two hand-kept copies -- SHIPPED in check_plugin.py and the workflow's paths:
filter -- with a comment asking a human to keep them in step. That is the
shape #3127 section 3 warns about, and both copies had drifted.
The load-bearing change is the exclusion. The version check reads "did
shipped content change against the base?", and plugin.json lives INSIDE
plugin/ -- so bumping the version is itself a change to the set, which then
reads as the change that justifies the bump. Every bump passed, no bump could
ever fail, and the check proved nothing while looking green.
manifest_differs_beyond_version compares parsed objects with `version`
dropped from both sides. One field, never the whole file: plugin.json also
carries description, mcpServers and userConfig, all of which reach an install,
and excluding the file wholesale would let a userConfig-only edit compute an
unchanged version and never refresh -- #2209 again with a narrower trigger.
Unreadable input answers "changed", because a spurious bump costs one cache
refresh while a missed one is the fix reaching the repo and stopping there.
shipped_content_changed returns None, not False, when the diff fails. #2663 is
why: a read that failed inside a broad except reported the same zero as an
empty window, and every counter read zero for weeks.
Two dead trigger paths removed, both found by writing the guard rather than by
review. fable-mcp/** outlived its directory by three months (deleted in
91bafb6, 2026-05-27) and assets/** named a path that never existed at all. A
paths: entry matching nothing never fires, so neither ever failed anything.
Their two orphaned bump scripts go with them -- a third manual-bump mechanism,
wired into no settings file.
DERIVERS is section 3's (deriver -> artifacts whose identity it decides) table.
The membership test is "can changing this file change what the artifact says
about itself?", not "is it copied in" -- a deriver is never in the COPY list.
A checker is not a deriver, which is why check_plugin.py is absent from it;
step 3's mint script adds its own row.
The workflow's paths: filter is YAML and cannot import Python, so "one
definition" is held by drift tests rather than an import. Said plainly in the
test module, because it is the honest shape rather than the ideal one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DN4zBVFWhBST9YqjCfQmPb
The runner-facts step answered rule 81's check on its first run, and the
answer is the one the check itself warned about: `/bin/sh` resolves to
`/usr/bin/dash`, because ci-python is Debian-based. The constraint the
rule exists for is unchanged — dash has no /dev/tcp, no arrays, no
`[[ ]]` — but the shell has never been busybox, and this comment was
repeating the wrong name at the one place a reader would trust it.
Rule 81's own statement still says busybox; correcting it is a rulebook
edit and goes through propose -> approve -> apply.
Three conditional rules state facts about this act_runner — services are
not reachable by hostname (79), the service container's name is derived
from the job's truncated display name (80), and `run:` steps execute
under a shell without bash features (81). None had ever been verified,
because each check reads "add a step to a live CI job and read the log"
and nobody wants to arrange a throwaway run to do it.
So the step is not throwaway. Two lines on every integration run turn the
next sweep of these rules into a log read. Rule 80 needs nothing new: the
container listing the suite step already prints for the name filter is
its evidence, and run 5055's log already answers it.
Every command is guarded with a fallback. This observes the lane; it must
not be able to break it.
CI set BUILD_VERSION to the CHANNEL — literally "dev", "main", or the tag —
so a running instance answered "which build are you?" with the name of a
branch: {"version":"main"}. The cost was concrete rather than theoretical.
During #3244's live acceptance a deploy was behaving as though it held older
code, and the one endpoint whose job is to settle that could not.
Rule 149's three values, now three fields:
version the NAME, YYYY.MM.DD.HHMM from COMMIT time — "is this the same
code?", so two lanes carrying one commit report one string
build the ORDERING KEY, minutes since 2020-01-01 from BUILD time —
"may this be installed over that?", and the only value anything
may compare
channel its own field. Never a suffix, never a segment of the name
Plus `commit`, so the artifact's claim about itself can be checked against
the :<sha> it was published under (rule 145) — which is exactly the question
that could not be answered tonight.
THE TWO CLOCKS ARE DELIBERATE and look like an inconsistency. The name comes
from the commit so two lanes building one source agree; the key comes from
the build so it cannot go backwards when an older commit is rebuilt. A test
pins both derivations against being "tidied" into one.
ABSENT RATHER THAN EMPTY when unknown. A local build has no ordering key and
no channel; emitting "" or a placeholder would let it claim a position in an
update order it is not part of. A malformed key is dropped rather than passed
through — a reader that cannot order is correct, one that orders on garbage
is not. The key is an int, because a string ordering key is how a comparison
silently becomes lexicographic ("9" > "10").
The payload builder is extracted from the route so it can be tested as a
dict rather than through app startup and a request context.
Tests pin the SHAPE the lanes emit, not the values, including the midnight
leading-zero case rule 149 names specifically — and assert CI never stamps a
branch name as the version again.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CI's new integration tests reproduced the outage on a clean database and
named the half: the writes land fine, and usage_for_notes fails on EVERY
call. The GROUP BY rebuilt the ambient case() expression, and asyncpg's
expanding IN-parameters give each instance its own bind names — so Postgres
sees a SELECT expression the GROUP BY doesn't cover and rejects the query
with a GroupingError, which the old code swallowed into zeros. One labelled
expression, grouped by its label. The deployed table has been accumulating
events all along; history appears as soon as this deploys.
Also: the two hook-execution tests now run with the real PATH and skip
when the hook's tools are absent (the restricted-PATH convention next door
is for silence contracts, where empty-for-the-wrong-reason still passes) —
and the unit lane installs jq so 'skip' never quietly becomes 'nowhere'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
Closes the gap the theme.css repoint exposed (#2319, part of #2277).
`check_code_against_tokens` could always answer "does this code use the sheet
correctly?" — it was only ever fed recorded SNIPPETS. The app's own components,
where sixteen unresolvable references were living quietly, were checked by
nothing at all.
That was structural rather than an oversight: the drift panel runs in the browser
and cannot read source files, and the server has no repo access. CI is the only
place holding both the sources and the ability to run the check — and it only
became cheap once theme.css became a generated artifact, so the source of truth
is a committed file with no network and no credentials.
**The sheet now carries its own SUPERSEDES block.** That is what keeps the
checker instance-agnostic (rule #115): it knows nothing about any palette, and
reads both the declarations and the discouraged literals out of whatever
stylesheet it is pointed at. Hardcoding "#fff means use the text token" would
have baked one install's kit into the tool.
Two severities, split on whether the count is already zero:
FAIL an unresolvable var() reference — zero today, so this is a ratchet
holding a line already reached. It cannot false-positive either: the
name is declared or it is not.
REPORT superseded literals (32 files) and raw colour literals (246). Gating
those means a permanently-red job, and a check nobody reads is worse
than no check.
**Comments are stripped before scanning, and that fired on the first real run.**
A comment explaining why a literal is avoided necessarily contains that literal —
DesignSystemsView's stylesheet documents exactly that about `#fff`, and the
checker reported the explanation as a violation. A checker that flags the
documentation of a rule teaches people to stop documenting rules.
Also narrowed `--fs-weight-medium`'s supersedes to the keywords. `600` and `700`
are real violations of the two-weight rule, but a bare number matches too much to
find by literal scan — `z-index: 600` is not a font weight. That needs a
property-aware check, which is a different tool.
Verified end to end: the generator's output parses back through the checker's
reader, so the two halves cannot drift into disagreeing about the format.
Per-job installs, not an image change. CI-runner's docs/process.md decision
checkpoint is explicit: "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, and shellcheck is not a
natural extension of a Python image's purpose. Promotion into ci-python is
filed as an issue on CI-runner rather than assumed here — same doc, step 1:
the maintainer's call goes in the issue, then the PR.
This also corrects something I got wrong earlier in this work: I cited rule
#5 as blocking a per-job install. Rule #5 is about language TOOLCHAINS via
setup-* actions, not small lint utilities, and CI-runner's own process doc
positively recommends per-job installs in exactly this case.
jq is load-bearing rather than convenient. Every hook opens with
`command -v jq || exit 0`, so without it a "runs and stays silent" smoke
test passes while exercising nothing — a green tick proving less than no
test at all. That is why the smoke test didn't ship with the first cut.
The smoke test pins the fail-open contract: each hook, with no credentials
and then against a refused connection, must exit 0. Three must also stay
silent; scribe_session_context.sh must NOT, because its static behavioural
floor is meant to survive having no credentials and no network — asserting
silence there would encode the opposite of the design.
Verified it can actually fail, rather than assuming: injected a non-zero
exit and separately a stray stdout write, and confirmed each is caught.
shellcheck and jq are both optional at runtime — missing either SKIPs its
check loudly rather than passing. A check that quietly no-ops is the exact
failure mode this file exists to prevent.
ci-requirements.md updated: jq + shellcheck recorded under per-job installs
(the input CI-runner's maintainer uses for the next promotion decision),
plus two stale entries corrected — the sheet claimed four jobs when there
are six, and listed `uv` as a per-job install when it has been in the image
since the ci-python Dockerfile started pip-installing it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
Run 3027: the Plugin hooks job failed at checkout, before the script ran.
Adding a `with: fetch-depth: 0` block made actions/checkout@v6 fail to
extract on the act_runner —
Cannot find module '/var/run/act/actions/<sha>/dist/index.js'
— while every bare `uses: actions/checkout@v6` in the same run succeeded.
The runner's action-cache handling is the difference, not git.
No depth was needed in the first place. The version check compares two
TREES, and a tree diff needs both trees, not a common ancestor. Verified
against a real depth-1 clone: after `git fetch --depth=1 origin
main:refs/remotes/origin/main`, both `git diff origin/main -- plugin` and
`git show origin/main:plugin/.claude-plugin/plugin.json` work. So the
explicit fetch already in the step is sufficient, and cheaper than the
full history the `with:` block was asking for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
`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 against
c569cdd: 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 in c569cdd. 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
Completes the previous two commits. `--frozen` failed run 3007 with
`ModuleNotFoundError: No module named 'pgvector'` — the lock wasn't merely
stale in its recorded metadata, it was missing a real dependency. pgvector
was added to pyproject for the vector-search work and the lock was never
regenerated, and nothing noticed because CI resolved from pyproject and never
read the lock. The lock has been dead weight for some time.
Regenerated with `uv lock` inside a throwaway `ci-python:3.14` container —
this workstation has no uv and no pip, and the CI image already carries the
right toolchain, so nothing was installed to do it. uv was conservative as
promised: pgvector 0.5.0 added, and NOT ONE existing pin moved (verified by
diffing name=version pairs across all 106 packages).
Both lanes now run `uv sync --locked`, so a dependency edit without a re-lock
fails loudly at install rather than resolving around the lock. The check paid
for itself on its first run by surfacing the missing pgvector.
Also added uv.lock to the workflow's `paths:` filter. It was absent, so a
lock-only change — exactly what a dependency bump looks like now — would not
have triggered CI at all.
Closes the CI half of #2194. The Dockerfile still resolves independently and
is tracked there.
Run 3006 failed at the install step: "The lockfile at `uv.lock` needs to be
updated, but `--locked` was provided." The guard was working — the mcp cap
edited pyproject, so the lock genuinely is stale, and hand-editing the
recorded specifier wasn't enough to satisfy uv's freshness check.
Regenerating needs `uv lock`, and this workstation has neither uv nor pip
(rule #10 — local Python envs are deliberately absent), so obtaining it would
mean pulling a binary from github.com, against rule #3. Not doing that
unilaterally.
--frozen installs exactly what the lock pins and resolves nothing, which is
the whole point of #2194: no dependency can float into a run again. What it
gives up is only the staleness check — and a forgotten re-lock surfaces as a
loud ImportError, not as a silent version drift, so the failure mode is the
tolerable one.
Flip to --locked in the same change that runs `uv lock`. Refs #2194.
Closes the reproducibility hole that turned `main` red an hour ago. CI ran
`uv pip install -e ".[dev]"`, which resolves from the pyproject constraints
and ignores uv.lock completely — so every dependency floated. uv.lock pinned
mcp 1.27.2; CI installed the 2.0.0 published mid-session and the identical
tree that passed on `dev` failed on `main`.
Both Python lanes now run `uv sync --locked --extra dev`. `--locked` also
fails when the lock is stale against pyproject, so a dependency change has to
go through a deliberate `uv lock` instead of arriving on its own — which also
restores the point of the Renovate dashboard-approval flow.
Dropped the http-ece install and the setuptools/wheel step that existed only
to support it: nothing in src/ or tests/ imports http_ece. It is a leftover
from the web-push subsystem removed in the MCP-First pivot, and it was never
in pyproject or uv.lock — CI was installing an unused package and carrying a
--no-build-isolation workaround for it.
Cache key moves from pyproject.toml to uv.lock, since the lock is now what
determines the installed set.
uv.lock's recorded root requirement updated to match the mcp cap. Edited by
hand rather than regenerated: uv isn't installed on this workstation, the
resolved mcp 1.27.2 already satisfies `<2`, so no re-resolution is needed —
only the staleness check needed satisfying.
The Dockerfile still resolves independently (`pip install .`, and it doesn't
even copy uv.lock), so the shipped image is not yet covered. Following
separately so a build break can't strand `main`. Refs #2194.
Move semantic_search_notes off the full-table Python cosine scan onto a native
pgvector column: indexed ORDER BY embedding <=> :q LIMIT k (HNSW, cosine).
Migration 0067 enables the extension, converts the JSONB embedding column to
vector(384) (stale-dim rows dropped and regenerated by the startup backfill),
and builds the HNSW cosine index. Postgres image moves postgres:16-alpine ->
pgvector/pgvector:pg17 across prod, quickstart, and CI.
Scribe: project 2, milestone 93, task 1031.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xz4j1H7pjYSjKsEpgcNH5E
The unit suite can't catch sync/async API mismatches against SQLAlchemy (an
un-awaited execution_options passed green CI but failed at runtime: VACUUM 0/6).
Add a real-Postgres integration lane modelled on the family pattern (rules
6/79-82): a new CI 'integration' job with a postgres:16 service, bridge-IP
discovery, busybox-safe readiness wait, and 'alembic upgrade head', running
pytest -m integration. Non-gating, like the unit lane.
- tests/test_integration_db_maintenance.py: runs run_maintenance() and
get_table_health() against real Postgres; asserts all allowlisted tables
vacuum OK (the await regression makes this fail) and health reports real stats.
- pyproject: register the 'integration' marker.
- conftest: integration-marked tests use the real DATABASE_URL, not the stub.
- ci.yml: unit 'test' job now runs -m 'not integration'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The TypeScript-typecheck job intermittently failed at 'Cache npm download
cache' (transient cache-backend hiccup), which skipped install + type check and
marked the run red — 3x during the issues+systems build, all on pushes the
cache step had no bearing on. continue-on-error: true degrades a cache failure
to 'install without cache' instead of failing the job.
Closes the rerun churn from task #828.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
main pushes now move :latest (in addition to the immutable :<sha>), so a
merge to main updates production's pointer directly — no separate release
needed just to refresh :latest. The v* release tag's distinct job becomes
the dated :<version> marker (it still refreshes :latest harmlessly). Still
no :main tag. Rules 47/46 + 10/4 updated to match on both instances.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
:latest (release-only) is the single production pointer; a :main moving
tag just duplicated it. main pushes still gate + build (the :<sha> image
is the rollback point), but no longer publish a :main alias. The tag was
new and unreferenced, so nothing depends on it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously main pushes were deliberately skipped — CI only ran on dev
and v* tags. This conflicted with the intended policy (CI on dev AND
main). Now main is a first-class gated, built line: dev->:dev, main->:main,
v* tag->:latest + :<version>, every build also tagged with the commit sha.
Per-ref concurrency already supersedes rapid pushes, so dev and main run
independently without stacking identical work.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Buried smoking gun: every CI run since the ci-python:3.14 migration
has silently failed to push the `:dev` tag. The build logs for commit
2a374d9 show:
/var/run/act/workflow/tags.sh: 4: [[: not found
/var/run/act/workflow/tags.sh: 6: [[: not found
act_runner invokes the workflow's `run:` block with `sh -e` (dash on
Debian-based ci-python:3.14, NOT bash). The original bash-only `[[ ]]`
syntax failed silently, the `:dev` tag never got appended to TAGS,
and only the SHA-tagged image was pushed. The `:dev` tag in the
registry has been stuck on whatever build last managed to push it —
likely back when CI ran on a bash-y Ubuntu runner before the migration.
This is why the deployed stack has been running a stale image despite
multiple successful "CI passed" runs: it pulls `:dev`, and `:dev` was
months out of date.
POSIX `case` is dash-compatible AND bash-compatible. Same intent
(decide which extra tags to append based on ref); no behaviour change
other than actually executing correctly.
This commit itself touches .forgejo/workflows/ci.yml, so it triggers
a fresh CI run that — for the first time in a while — should push
both :<sha> AND :dev. After this lands, redeploying the stack will
finally pull the recent code.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets you re-run CI from the Forgejo Actions UI without needing a
trivial commit. Useful when:
- An image has been built but the deployed stack didn't pick it up
(re-run forces a fresh push + any post-CI hooks fire again).
- A transient upstream issue caused a build to fail (HF download
flake during the voice-bundle step, registry hiccup, etc.) and
re-running against the same source produces different behaviour.
This commit itself touches .forgejo/workflows/ci.yml so it triggers
a build by the normal paths rule, giving you a fresh :dev image
right now in addition to enabling future manual re-runs.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Migrate to the FabledRulebook CI-Runner contract:
- .forgejo/workflows/ci.yml: all four jobs (typecheck/lint/test/build)
now schedule on the `python-ci` runner label and run inside
container.image: git.fabledsword.com/bvandeusen/ci-python:3.14
(Python 3.14 + Node 24 + ruff + uv + Docker CLI). Dropped the inline
uv install in the test job — uv is now baked into the image.
- Dockerfile: production runtime bumped to python:3.14-slim so test
results stay representative against what we ship.
- ci-requirements.md: new file at repo root declaring image deps and
per-job installs (per FabledRulebook ci-runners.md).
- infra/Dockerfile.runner-base: deleted. The in-repo runner base
(Ubuntu 24.04 + Python 3.12 + Node 22) is superseded by the shared
ci-python image. The runner-host deployment files
(runner-compose.yml + act-runner-config.yml) stay as deployment-shape
documentation; source of truth is the deployed config.
- docs/development.md: CI/CD + Runner sections refreshed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Updates user-visible branding across frontend (PWA manifest, page title,
Settings, push fallback), backend (email templates and subjects, LLM
system prompt, CalDAV displayname, SMTP from-name default), README,
quickstart compose, and MCP server description.
Also updates the CI image path and quickstart image reference to
git.fabledsword.com/bvandeusen/fabledscribe in preparation for the
Forgejo repo rename. Internal Python package, env vars, and database
schema unchanged.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Collapse briefing from 3-column to 2-column grid (chat + sidebar).
Weather moves to top of news column, forecast uses an HTML table so
rows align cleanly. Drop verbose condition text from forecast days
(emoji already conveys it). Switch CI node_modules cache to npm
download cache to avoid tar size error from onnxruntime-web.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Forgejo Actions doesn't consistently honor `on.push.branches` as a
filter — the merge commit landing on main after a release PR was still
triggering the full CI workflow on a SHA that had already passed on
dev, burning ~1 minute of runner time for no reason.
The `build` job already had a ref guard, so no duplicate images were
being pushed, but typecheck/lint/test ran unconditionally. Add the
same guard (`refs/heads/dev` or `refs/tags/v*`) to those three so main
pushes trigger the workflow but every job skips immediately with zero
runner time.
Also tightened the build job's tag filter from `refs/tags/` to
`refs/tags/v` for consistency with the new guards and to avoid ever
building from a non-version tag.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The Forgejo registry occasionally returns 400 on large cache layer
blob uploads, failing the entire build even though the image itself
pushed successfully. Adding ignore-error=true to cache-to so cache
failures don't block deployments.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
uv creates bare venvs without setuptools (unlike pip). http-ece
doesn't declare setuptools as a build dependency but needs it when
built with --no-build-isolation.
The py3.12-node22 runner doesn't ship pipx, so the previous commit's
pipx install ruff failed with command not found. Switched to the same
venv pattern the test job uses.
Three sources of unbounded growth removed:
- Drop cache-from/cache-to registry: on a persistent self-hosted runner the
local BuildKit layer cache already provides between-run reuse; the registry
cache was redundant and pushed ~2 GB of torch layers on every build
- Switch docker system prune -f → -af so old :SHA-tagged images are removed,
not just dangling ones (-f alone never touched named tags)
- Add docker builder prune --keep-storage 5g to bound the local BuildKit
cache; pip mount cache (torch etc.) is recently-used so survives, stale
intermediate layers are evicted first
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
build-push-action@v7 generates OCI attestation manifests by default.
Forgejo's registry doesn't support OCI image index format with attestations,
causing the push to fail with "unknown". provenance: false disables this.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add `# syntax=docker/dockerfile:1` to enable BuildKit cache mounts
- Replace `--no-cache-dir` pip installs with `--mount=type=cache,target=/root/.cache/pip`
so torch/CUDA wheels are reused across builds instead of re-downloaded every run
- Add `docker system prune -f` step before build to free dangling image/layer space
- Add `cache-from`/`cache-to` pointing to `:cache` tag so unchanged layers
(including the heavy voice-deps layer) are pulled from registry instead of rebuilt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
All code goes through dev first where CI runs on push. PR runs
(dev→main) were duplicating already-validated checks and causing
queue contention. Build job was already gated to push-only events.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Even mode=min still hits the registry's blob size limit (400 Bad Request).
The local runner's Docker daemon layer cache is sufficient for fast
incremental builds without needing a separate registry cache tag.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mode=max exports all intermediate layer blobs which exceeded the Forgejo
registry's blob upload size limit (400 Bad Request). mode=min only exports
the final image layers, keeping cache entries small enough for the registry.
The actual image build and push was succeeding; only the cache write failed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Merging to main now runs CI checks only. The release tag (v*) is the
sole trigger for a production :latest image, eliminating the redundant
double-build that previously occurred on every PR merge + tag pair.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Dockerfile: ARG BUILD_VERSION=dev → ENV APP_VERSION baked into image
- ci.yml: BUILD_VERSION passed as build-arg; set to git tag name on v*
tag builds, "dev" on branch builds
- routes/api.py: GET /api/version returns {"version": APP_VERSION}
- SettingsView: fetches /api/version on mount, displays in About section
under General tab
Version source of truth is the git tag (YY.MM.DD.N CalVer).
pyproject.toml / package.json versions are no longer maintained.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Ubuntu 24.04 blocks pip install into the system Python by default.
Add --break-system-packages for the lint job's throwaway container.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The label now explicitly declares the dependency versions it provides
rather than using the opaque ubuntu-latest alias. All runs-on fields
in ci.yml updated to match.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- infra/Dockerfile.runner-base: new Ubuntu 24.04 image with Python 3.12
and Node 22 LTS pre-installed, tagged py3.12-node22. Eliminates the
~3-4 min deadsnakes PPA install that ran on every test job.
- infra/runner-compose.yml: update ubuntu-latest label to use
runner-base:py3.12-node22 instead of node:20-bullseye.
- .forgejo/workflows/ci.yml: remove Python 3.12 install steps (now in
base image), add actions/cache for pip keyed on pyproject.toml hash,
bump node-version 20 → 22.
- Dockerfile: bump frontend build stage node:20-alpine → node:22-alpine.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
deadsnakes python3.12 sees system python3.10's setuptools at
/usr/lib/python3/dist-packages and skips installation, leaving python3.12
without setuptools. A venv gets its own site-packages so pip/setuptools
are properly present for http-ece's legacy setup.py build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pip builds legacy setup.py packages in an isolated environment that doesn't
inherit the globally installed setuptools. Pre-building http-ece with
--no-build-isolation bypasses that isolation so setuptools is available.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
http-ece (pulled in by pywebpush) uses setup.py and requires setuptools,
which is not included in the deadsnakes Python 3.12 install.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
python:3.12-slim has no Node, causing actions/checkout@v4's post-step
to fail with 'exec: node not found'. Drop the container: declaration
so jobs run on the ubuntu-latest runner (which has Node). Install
Python 3.12 via deadsnakes PPA for the test job since the runner base
image (Ubuntu 22.04) ships with Python 3.10.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Merged ci.yml and build.yml into a single workflow. The build job now
declares needs: [typecheck, lint, test] so images are only pushed when
all checks are green. PRs run CI only; branch/tag pushes run CI then
build if successful.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
actions/setup-python fails in act_runner because it tries to download
pre-built binaries from GitHub CDN which aren't available in this
environment. Using a Python container image directly is the correct
approach — same pattern as the Flutter jobs in fabled_app.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>