Compare commits

...
276 Commits
Author SHA1 Message Date
bvandeusen 725bf15f88 Merge pull request 'AGPL-3.0, SECURITY and CONTRIBUTING; the install path pulls :latest, not :dev' (#244) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m43s
2026-08-31 17:01:20 -04:00
bvandeusenandClaude Opus 5 bc4eba636d fix: the documented install path pulled :dev, not :latest (#3270)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m44s
docker-compose.yml pinned :dev on all five app services — web, worker,
scheduler, maintenance-long, ml-worker. The README documents
`docker compose -f docker-compose.yml up -d` as the production path, and
-f means "use only this file", skipping the override and its build:
directives. So Compose pulled image:, and image: was the rolling
development channel. The documented way to install this product shipped
development builds.

It went unnoticed for a structural reason rather than a careless one:
nobody who works on the project takes that path. The operator deploys
from a swarm stack file; contributors get docker-compose.override.yml,
which sets build: for all five services, and build: wins over image:. The
broken path is reachable only by a stranger following the README — which
is exactly the audience that did not exist until now.

:latest, per rule 147: main IS production. It is also what the agent
stack (agent/docker-compose.yml) already pinned, so this makes the two
stacks agree rather than introducing a new convention.

Both paths verified with `docker compose config`, which merges and prints
without starting anything:

  dev path        — build: present on all five, image: not pulled
  -f production   — 0 build: directives, five :latest images resolved

Also checked the base file for anything a stranger could not satisfy:
no host-absolute volume paths, no operator-specific port bindings, no
device mappings. The tag was the only defect in the consumer path.

The comment on web.image is deliberately long (rule 32). A line reading
:latest inside a file a developer is debugging with is exactly the line
someone flips back to :dev to test something and then commits, and the
consequence — strangers silently installing bleeding edge — is invisible
to everyone who works here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
2026-08-31 16:55:43 -04:00
bvandeusenandClaude Opus 5 dbc4e8b0c6 docs: AGPL-3.0, plus SECURITY and CONTRIBUTING (#3269)
CI / lint (push) Successful in 2s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m52s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 31s
The repo had no LICENSE, which meant all rights reserved by default:
nobody could legally run or modify it, and "public availability" was a
contradiction no amount of install documentation could fix. This is the
hard blocker in milestone 328; everything else in it is quality.

AGPL-3.0, at the operator's explicit choice, for the reason the operator
gave: this should not become something another party runs as a hosted
proprietary service. Section 13 is what makes it fit — for a self-hosted
web app, distribution otherwise never happens, so the GPL's obligation
would never actually bite. AGPL reaches the case that matters here:
running a MODIFIED copy as a service for others.

LICENSE is the FSF text fetched from gnu.org and verified byte-identical
(34,523 bytes, 661 lines, §13 "Remote Network Interaction" present), not
retyped. README's old "Personal project; use at your own discretion" said
nothing legally and is replaced with what the licence actually asks —
including the part worth being clear about, that running an unmodified
copy for yourself carries no obligation whatsoever.

SECURITY.md names what this software actually holds, because that is what
makes a report serious here: live third-party session cookies for
accounts with payment methods attached, the extension API key, the
multi-user sharing ACL, and arbitrary downloaded media that gets decoded
and fed to models. It also states the plain-HTTP posture up front, so
"served over HTTP" and "no HSTS" are understood as the documented design
rather than filed as findings.

There is no private disclosure channel yet, so the reporting instruction
is to open an issue containing NOTHING but the fact that a report exists,
and wait for a private contact. Awkward on purpose: an issue tracker is
public the moment it is written to, and every self-hosted instance stays
vulnerable until its operator can update. Worth replacing with a real
contact address — that decision is the operator's, since it publishes one.

CONTRIBUTING records the two things that actually catch people: ruff's
order-by-type import sorting, and that a model change and its migration
belong in the same commit. The second is not style — the models and the
chain silently diverged for a long time (#3275) and autogenerate was
unsafe as a result.

Pre-publication scan, since the repo is about to get attention:
.env.example is placeholders only (`changeme_*`), `.env` is gitignored
with an `!.env.example` exception, no credential-shaped literals are
committed, and there are no private IPs or operator home paths. The only
hostnames are the project's own forge, which is public by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
2026-08-31 14:53:06 -04:00
bvandeusen b14818303c Merge pull request 'Schema reconciliation + index hygiene, and the weekly base-image refresh' (#243) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 14s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 14s
extension / lint (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m54s
2026-08-31 08:34:55 -04:00
bvandeusenandClaude Opus 5 08418d54a3 db: index the seven unindexed FKs, drop the seven redundant ones (#3300, #3301)
Build images / build-ml (push) Successful in 32s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 11s
Build images / build-web (push) Successful in 26s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
CI / integration (push) Successful in 3m44s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 30s
extension / lint (pull_request) Successful in 24s
A structural sweep of the deployed schema, run AFTER 0088 got the models
and the chain to exact agreement. That agreement is what 0088 achieved,
and it is worth naming what it does not prove: a models-vs-chain diff
shows the two describe the same schema, not that the schema is right.
Everything here was wrong in BOTH.

The one that matters: image_tag has PRIMARY KEY (image_record_id, tag_id)
and no other index, so tag_id is unindexed. That is the gallery's tag
filter (tag_query.py builds `image_tag.c.tag_id == tid`) and the ON
DELETE CASCADE from tag, both scanning the largest table in the schema.
Six more FKs were unindexed on smaller tables; presentation_review.tag_id
also CASCADEs.

Dropped, on the other side: ix_image_record_sha256 was an exact duplicate
of the index uq_image_record_sha256 already builds — two btrees on the
same column of the highest-insert-rate table. The other six are
single-column indexes a later composite superseded without the narrow one
being retired; a btree on (a,b) already serves lookups on a.

0088 deliberately taught the models to declare BOTH sha256 indexes so
they would describe reality. This changes the reality instead, and the
models change with it — otherwise the next baseline.yml run reintroduces
exactly the drift 0088 removed.

CONCURRENTLY throughout, so building the image_tag index does not hold an
ACCESS EXCLUSIVE lock over every write for the duration. The cost is that
the migration cannot run in a transaction and so is not atomic: every
statement is IF NOT EXISTS / IF EXISTS, making a re-run after a partial
failure safe. The docstring carries the query for finding an INVALID
index left by an interrupted CONCURRENTLY build.

What the sweep found clean, for the record: all 43 tables have a primary
key; all 51 FKs declare an explicit ON DELETE, so none silently blocks a
delete; the three enum CHECKs match the code that writes them (rule 36).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
2026-08-31 08:25:41 -04:00
bvandeusenandClaude Opus 5 b1bd2531ad style: sort JSON first in three sqlalchemy import blocks
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-web (push) Successful in 43s
Build images / build-ml (push) Successful in 51s
CI / integration (push) Successful in 3m47s
ruff's isort runs with order-by-type, which sorts ALL_CAPS names ahead of
CamelCase ones, so `JSON` belongs at the head of the list rather than
between `Integer` and `String`.

Two of these (backup_run.py, post.py) have been failing lint since
5e1996e — I did not check the push CI after that commit, only the
baseline workflow I had dispatched, so ci.yml has been red on dev across
5e1996e, ed2b1ad and d044e93.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
2026-08-31 00:38:46 -04:00
bvandeusenandClaude Opus 5 389afe2f7b db: the doubled CHECK list was six, not four (#3275)
CI / extension-version (push) Successful in 6s
CI / lint (push) Failing after 6s
Build images / sign-extension (push) Successful in 6s
Build images / build-agent (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 31s
CI / frontend-build (push) Successful in 22s
Build images / build-ml (push) Successful in 52s
CI / integration (push) Successful in 3m43s
Build images / build-web (push) Successful in 41s
Run 5029 confirmed the four renames landed and surfaced two I had missed:
external_link's host and status CHECKs are doubled the same way. They did
not show in run 5026's diff because BOTH sides produced the doubled form
back then — external_link.py pre-prefixed its names, so the models
matched the chain's mistake. Switching all six models to bare names is
what exposed the two the migration did not cover.

The list in the file now comes from matching ck_(\w+?)_ck_\1_ against the
chain's own pg_dump, rather than from reading migrations by eye. Reading
by eye is what missed these, in the same way it earlier missed a UNIQUE
constraint sitting two lines above the index being looked at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
2026-08-31 00:33:50 -04:00
bvandeusenandClaude Opus 5 b979062dd7 db: rename the four double-prefixed CHECK constraints (#3275)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Failing after 5s
CI / extension-version (push) Successful in 5s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-ml (push) Successful in 44s
Build images / build-web (push) Successful in 41s
CI / integration (push) Successful in 3m52s
Run 5026 got the models-vs-chain diff to 7 lines. Three findings, and one
of them reverses an assumption I made in the previous commit.

The doubled CHECK names are what the DATABASE has, not what the generator
invented. base.py's convention is ck_%(table_name)s_%(constraint_name)s,
which — unlike uq/fk/ix — applies even to a constraint that already has a
name, so four migrations that passed an already-prefixed name got it
prefixed twice:

    ck_import_settings_ck_import_settings_singleton
    ck_ml_settings_ck_ml_settings_singleton
    ck_post_ck_post_translation_override
    ck_tag_ck_tag_fandom_requires_character

The workflow repair added last commit is still correct and still needed —
autogenerate really does re-double a name on the round trip — but it was
making the MODELS side clean against a chain that is dirty. The
comment in ml_settings.py claiming its bare name "matches migration 0003"
was simply false; 0003 produces the doubled form.

Nothing reads a CHECK constraint by name, so this has never done harm.
But it is precisely the development-era residue the collapsed baseline
exists to leave behind, and a public schema should not ship it — so 0088
renames the deployed constraints and all six models now declare bare
names. RENAME CONSTRAINT is catalog-only: no scan, no rewrite, no
revalidation, which is why this is safe on post and tag. Guarded on
pg_constraint scoped by conrelid, so it is a no-op on a database built
from the models.

ix_tag_fandom_id showed as a difference only because chain_ref was pinned
to 0a5bbe8, which predates 0088 — the comparison was measuring the models
against a chain missing the migration that closes the gap. chain_ref now
defaults to blank, meaning "the chain in this ref". Pin it to a commit
only after the collapse, when the tree no longer carries the revisions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
2026-08-31 00:29:57 -04:00
bvandeusenandClaude Opus 5 573228b9da db: finish reconciling the models with the deployed schema (#3275)
CI / lint (push) Failing after 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 34s
Build images / build-ml (push) Successful in 53s
Build images / build-web (push) Successful in 44s
CI / integration (push) Successful in 4m5s
CI / backend-lint-and-test (push) Successful in 1m6s
Closes the residue the first reconciliation pass left, and corrects a
factual error I put into the record.

sha256 was NOT missing a uniqueness guarantee. I read
`op.create_index("ix_image_record_sha256", ...)` at 0001 line 151 and
concluded duplicates were possible, without reading line 149 two lines
above it:

    sa.UniqueConstraint("sha256", name="uq_image_record_sha256"),

Uniqueness has held since the initial schema. The database expresses it
as a CONSTRAINT plus a separate non-unique lookup index; the model said
`unique=True, index=True`, which is one UNIQUE index under a different
name. Same guarantee, different objects — which is exactly why the two
schemas did not line up. The model now declares both objects. No DDL.
0088's docstring, which repeated the claim, is corrected in place.

Two real divergences, both the MODEL over-claiming:

  * source: uq_source_artist_platform_url (alembic 0010) was declared
    nowhere in the models — source.py had no __table_args__ at all — so
    autogenerate would have proposed DROPPING it.
  * head_metrics_snapshot.tag_id: model said NOT NULL, 0060 created it
    nullable. Left nullable; the FK already cascades.

Seven constraints renamed to what the chain actually created, rather than
what base.py's naming convention renders: uq_series_page_image,
uq_series_chapter_anchor_page, fk_series_chapter_anchor_page,
fk_image_record_artist_id, fk_image_provenance_from_attachment, and the
two hand-shortened fk_tsr_* names from 0003.

Float server_defaults now mirror their own migration, per column. The
chain is MIXED: a plain string renders DEFAULT '0.90'::double precision,
sa.text() renders DEFAULT 0.90, and the migrations used both. Seven
columns take text(); the rest stay strings. Two literals also disagreed
outright — process_{auto_apply,conflict}_threshold said 0.9/0.5 against
the migration's 0.90/0.50.

baseline.yml gains two things. A repair for a SECOND generator defect in
the same class as the missing pgvector import: base.py's ck convention
contains %(constraint_name)s, so it applies even to a NAMED
CheckConstraint — autogenerate writes the already-rendered name into the
migration and running it applies the convention again, yielding
ck_ml_settings_ck_ml_settings_singleton. That is round-tripping damage,
not a claim the models make, so it is undone rather than counted.

And the diff now runs twice. Column ORDER differs permanently between a
schema built by 87 ADD COLUMNs and one built in a single shot — the
operator's database keeps chain order forever, a fresh install gets model
order — so a check that failed on it could never pass. The second pass
SORTS column lines within each CREATE TABLE instead of deleting them,
which cannot hide a column present on one side only, or one whose type,
nullability or default differs. Ordered diff is reported as information;
the order-insensitive one is the verdict.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017QHszn9H8VBvx5Ke8x1hvw
2026-08-31 00:24:00 -04:00
bvandeusen d044e93bdb ci: repair autogenerate's missing pgvector import before applying (#3275)
CI / lint (push) Failing after 3s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 13s
CI / integration (push) Successful in 3m52s
mode: models applies the raw autogenerated candidate, and it cannot run:

  sa.Column('weights', pgvector.sqlalchemy.vector.VECTOR(dim=1152), ...)
  NameError: name 'pgvector' is not defined

Alembic emits the qualified reference without emitting the import.
Observed on run 4988, which turns this from a thing I predicted by
reading the candidate into a thing demonstrated by executing it.

Repaired in the workflow rather than counted as a schema difference: the
comparison asks whether the MODELS describe the schema, and this is a
defect in the generator. The same fixup has to be applied by hand to any
baseline generated this way, which is why it is item 4 on the collapsed
baseline's hand-written list.
2026-08-30 14:47:29 -04:00
bvandeusen ed2b1adc2e ci: compare the schema the MODELS produce against the migrations (#3275)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Failing after 4s
CI / extension-version (push) Successful in 4s
Build images / build-agent (push) Successful in 11s
Build images / build-ml (push) Successful in 47s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 35s
Build images / build-web (push) Successful in 37s
CI / integration (push) Successful in 3m53s
baseline.yml only ever compared migrations against migrations. The
question #3275 exists because nobody had ever asked the other one: does
a database built from the MODELS match the one the chain produces?

`mode: models` answers it. It applies the candidate autogenerated from
the models instead of this tree's revisions, and diffs that against the
chain. A clean run means --autogenerate is trustworthy again, which it
demonstrably has not been: against the pre-reconciliation models it
would have proposed dropping eleven indexes and two uniqueness
guarantees.

The two extensions are created by hand in that mode. They are database
objects rather than table metadata, so no model can carry them — their
absence is outside what this comparison asks about, and silently
tolerating it is correct rather than a filter that hides a defect.

Also declares the HNSW index on the ImageRecord model. SQLAlchemy can
express an hnsw access method with an operator class
(postgresql_using + postgresql_ops), so there was never a reason for it
to live only in 0036. That removes the last item from the list of things
a generated baseline cannot reproduce, leaving only the two extensions.
2026-08-30 14:43:26 -04:00
bvandeusen 5e1996e77f db: reconcile the models with the deployed schema (#3275)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Failing after 2s
CI / extension-version (push) Successful in 2s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 27s
Build images / build-ml (push) Successful in 48s
CI / backend-lint-and-test (push) Successful in 1m7s
Build images / build-web (push) Successful in 40s
CI / integration (push) Successful in 4m1s
Milestone 328's acceptance test compared a database built by the real
0001..0087 chain against one built from the models, and found ~130
places where they disagree. This closes them.

Almost all were the MODEL being wrong, so almost all of this is model
edits with no DDL — the database already had these things, nothing in it
changes, and no deploy is needed for this part:

* 92 columns gained server_default. The models carried Python-side
  `default=` only, so the ORM filled the value and the column had no
  database default. Anything inserting outside the ORM behaved
  differently from production.

* Eleven indexes that existed only in migrations are now declared:
  the three backup_run reporting indexes, the two date-ordered
  image_record browse indexes, import_task and presentation_review,
  and the three task_run history indexes. All use text() for their DESC
  ordering and postgresql_where for the partial one.

* Two UNIQUE indexes that autogenerate silently proposed DROPPING,
  because neither is expressible as a UniqueConstraint:
    uq_tag_name_kind_fandom  — an EXPRESSION index over
                               (name, kind, COALESCE(fandom_id, 0))
    uq_post_artist_external_id_null_source — PARTIAL, WHERE source_id
                               IS NULL
  post.py already had a comment describing the second one. The comment
  was right; nothing declared it.

* The two external_link enum CHECKs (host, status) — rule 36 territory,
  and absent from the model entirely.

* Two indexes were named explicitly. A bare index=True generated
  ix_tag_alias_canonical_tag_id where the database has
  ix_tag_alias_canonical, so autogenerate proposed a drop+create of an
  index that was already there under another name. Same for
  tag_suggestion_rejection.

Only ONE thing needed DDL, as 0088: tag.fandom_id is declared
index=True but no migration ever created that index.

Deliberately NOT here: image_record.sha256. The model says unique=True;
0001 created a plain index. Duplicates are possible today and the ORM
believes otherwise. The fix depends on whether duplicates already exist
— if they do, that is a dedupe decision, not a constraint — so it waits
on an answer about live data.

The real severity of #3275 is not the squash. It is that --autogenerate
has been unsafe on this project: run against the old models it would
have proposed dropping eleven indexes and two uniqueness guarantees.
2026-08-30 14:42:30 -04:00
bvandeusen 98b56330d0 ci: emit the chain schema dump for local reconciliation work (#3275)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 26s
Build images / build-ml (push) Successful in 44s
CI / backend-lint-and-test (push) Successful in 43s
Build images / build-web (push) Successful in 36s
CI / integration (push) Successful in 3m55s
Reconciling the models against the deployed schema needs the actual
pg_dump, not an inference from the unified diff. Parsing table context
out of diff hunks drops every table whose CREATE TABLE line falls
outside a hunk — it under-reported 81 columns across 13 tables when the
real figure spans more, missing artist, gpu_job, download_event and
external_link entirely.

Same checksummed-base64 transport as the candidate baseline, for the
same reason: a plain cat of a file this size was silently truncated
mid-line by the runner on run 4964.
2026-08-30 14:35:02 -04:00
bvandeusen 6959e1220c Revert "db: collapse alembic 0001..0087 into one baseline"
This reverts 2529b51. Not a retreat — a reordering, on the operator's
call, and the better sequence.

The squash's acceptance test (run 4971) found ~130 places where the ORM
models do not describe the deployed schema (#3275), including a
unique=True the database never had and two UNIQUE indexes that exist
only in migrations. Collapsing now would have baked all of that into the
one file a public installer starts from.

So: fix the drift first as ordinary migrations on the intact chain, let
the operator deploy so their database moves to the corrected head, and
only then collapse. The baseline is then generated from reconciled
models and reproduces a schema worth reproducing.

Nothing is lost by reverting. The baseline was never deployed, and
regenerating it after the fixes is strictly better than patching this
copy — it will come out of autogenerate correct rather than needing the
same hand-finishing twice.
2026-08-30 14:34:28 -04:00
bvandeusen 2529b516e6 db: collapse alembic 0001..0087 into one baseline (milestone 328 step 1)
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / frontend-build (push) Successful in 24s
Build images / build-ml (push) Successful in 42s
CI / backend-lint-and-test (push) Successful in 53s
Build images / build-web (push) Successful in 33s
CI / integration (push) Failing after 3m47s
87 revisions narrating this project's build-out become one file that
creates the schema in a single step. They cost nothing at runtime — all
86 upgrade steps ran in 0.2s (note #3260) — so this is a presentation
change, not a performance one: a new installer should not inherit our
development history to stand up a database.

Deleted: 87 revisions (6,052 lines), the 10 tests/test_migration_*.py
files (483 lines) that asserted intermediate states and backfills which
no longer exist, and backend/app/utils/artist_backfill.py — the only
live module a migration imported, with no other consumer anywhere. That
last one satisfies the operator's separate request to inline it into
0008 and delete the module; the squash removes both outright.

THE REVISION ID IS "0087", NOT "0001", ON PURPOSE. It is the id of the
last revision collapsed, so an existing database is already at head and
`alembic upgrade head` does nothing. The alternative is `alembic stamp`
against live data, and stamp validates NOTHING — it writes a version
string whether or not the schema matches, so a wrong baseline surfaces
later, via the next real migration, with no clean way back. This removes
that operation rather than making it safe. Future revisions run from
0088.

Four things are hand-written because SQLAlchemy metadata does not carry
them, and none fail at generation time:

  1. CREATE EXTENSION vector          — the VECTOR columns cannot be
     created without it, so it is ordered first in upgrade().
  2. CREATE EXTENSION tsm_system_rows — surfaces only when the random
     sample query runs.
  3. the HNSW index on image_record.siglip_embedding, raw SQL because
     create_index cannot express USING hnsw (... vector_cosine_ops).
     The quietest of the four: everything works, similarity search just
     stops using an index.
  4. import pgvector.sqlalchemy.vector — autogenerate EMITS
     pgvector.sqlalchemy.vector.VECTOR references without importing it,
     so the generated file dies with NameError on first run.

The candidate came out of CI (run 4967) as checksummed base64 rather
than a plain cat, because run 4964's cat was truncated mid-line inside a
column definition with the step still green — 29 tables instead of 42,
and it looked entirely plausible. Verified here: 56,582 bytes,
sha256 471acfca69c0…, 42 tables, 66 indexes, 42 drops.

NOT YET PROVEN against the old chain. baseline.yml does that, and it is
step 2's gate; this commit does not claim the schemas match.
2026-08-30 13:45:35 -04:00
bvandeusen 8f1ac0c96a ci: transport the candidate baseline as verifiable base64
CI / integration (push) Successful in 3m48s
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 5s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 45s
Run 4964 passed the control (1121 normalised lines, schemas identical)
but its candidate print was silently truncated. `cat` of the ~33KB
generated file stopped mid-line inside

  sa.Column('mime', sa.String(length=128)

and the runner carried straight on to the next traced command with the
step still green. The captured text was 484 lines and 29 tables, and
looked entirely plausible — which is exactly what makes it dangerous:
a schema definition cut in half is still syntactically suggestive, and
nothing in the log says it was cut.

Now emitted as base64 at a fixed 120-column width, followed by a
sha256, a byte count and a base64 line count. Short lines instead of
long ones, and more importantly the receiving end can PROVE it got the
whole file rather than trusting that it did.

Also found in that output, and the reason the candidate could never
have been committed as-is: it references

  pgvector.sqlalchemy.vector.VECTOR(dim=1152)

for head_training_run.weights and image_record.siglip_embedding, but
autogenerate does not add the corresponding import. The file would die
with NameError on the first run. That is the fourth item on the list of
things the generator cannot be trusted with, alongside the two CREATE
EXTENSIONs and the HNSW index.
2026-08-30 13:40:15 -04:00
bvandeusen 5fd171a544 ci: fix two things the baseline control run found
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-web (push) Successful in 6s
CI / integration (push) Successful in 3m52s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 32s
Run 4960 was the control — the chain compared against itself, which must
come back clean before a clean diff after the squash means anything. It
did its job and failed on both counts.

1. The harness is sound. Both dumps came back 1123 normalised lines and
   differed on EXACTLY two, the \restrict / \unrestrict pair that newer
   pg_dump emits to fence a dump against injection during restore. It is
   a fresh random nonce per invocation, so it differs by construction and
   is noise by definition. Now filtered — and the control is what
   licenses that filter: it was OBSERVED to be the only false positive
   rather than assumed to be one, which matters for a check whose whole
   value is that its normalisation does not hide a real difference.

2. The candidate-baseline step never ran. `if: github.event.inputs
   .generate == 'true'` on a `type: boolean` input silently evaluated
   false — no diagnostic, step skipped, job carried on. The same
   `github.event.inputs` typing quirk build.yml already works around for
   force_build.

   Rather than fight the input typing, the gate is now the tree itself:
   skip if alembic/versions holds one file. That is the real question
   anyway — there is nothing to generate once the chain is collapsed —
   and it cannot be silently wrong the way an unevaluated expression can.

Worth noting what the control also proved incidentally: the two schemas
were byte-identical across 1123 lines despite being built by separate
alembic runs into separate databases, so pg_dump's object ordering is
stable enough to diff directly and no sort normalisation is needed.
2026-08-30 13:35:26 -04:00
bvandeusen 62583791d8 ci: a workflow that proves a collapsed alembic chain matches the old one
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
CI / frontend-build (push) Successful in 25s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 43s
CI / integration (push) Successful in 3m59s
Milestone 328 step 1 needs a baseline generated from the models, and
step 2 must not stamp the operator's live database until that baseline
is proven to reproduce what the 87-revision chain produced. `alembic
stamp` validates nothing, so an unproven baseline fails silently now and
loudly later, on real data.

There is no local Python environment and rules 10/12 point away from
standing one up, so the comparison runs in CI, where a pgvector Postgres
is already built from the chain on every integration run and nothing is
at risk.

It builds two databases and diffs their pg_dump --schema-only output:
one from `alembic upgrade head` on the revisions read out of git at
`chain_ref`, one from the current tree. Reading the chain from git via a
worktree — rather than from the working tree — is what keeps this usable
AFTER the old revisions are deleted, so it is the proof for step 1 and
the pre-flight for step 2 rather than a one-shot script.

Both sides use `alembic upgrade head`, never metadata.create_all, per
rule 82 — and that rule's reasoning is exactly the hazard here.
`create_all` emits plain CREATE TABLE and skips everything else, which is
why the optional autogenerated candidate CANNOT be trusted as the answer.
Three things in this schema are invisible to SQLAlchemy metadata:

  CREATE EXTENSION vector           (0001)
  CREATE EXTENSION tsm_system_rows  (0004)
  the HNSW index on image_record.siglip_embedding, raw SQL because
    alembic's create_index cannot express USING hnsw (...)   (0036)

plus any CHECK constraint or server_default a migration added without the
model declaring it — 4 model files declare CheckConstraints against 6
migrations that touch them. The candidate is a starting point to hand
finish; the diff is what proves nothing was missed.

Results are printed to the job log rather than uploaded: ci-requirements
records that this runner cannot do actions/upload-artifact@v4+, and the
repo dropped the action entirely in 2026-05.

Run it first with the chain still present, as a control — the diff
compares the chain against itself and must come back clean. A clean diff
after the squash only means something if the harness was shown to be
capable of producing one beforehand.

Temporary. Delete once the baseline is stamped.
2026-08-30 13:31:16 -04:00
bvandeusen 0a5bbe81dc docs: the scheduled refresh does NOT republish nothing (#3265)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
Build images / build-agent (push) Successful in 8s
CI / integration (push) Successful in 3m58s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 52s
Step 4 asserted that when the base has not moved the refresh is "a ~13s
no-op that republishes nothing", and that this no-op was the point. The
first half is false and was written without being tested.

Run 4934, the first real fire: every content step reported CACHED and
both bases resolved to unchanged pinned digests, yet all three :latest
tags took a new manifest digest anyway.

  fabledcurator        4ea5265ba017 -> 380e504de0fa
  fabledcurator-ml     6e7cfc0c09fd -> 6b2eefc301d8
  fabledcurator-agent  44920e0af1f3 -> 54accbeb52ed

buildkit mints a fresh image config per run, so identical layers get
republished under a new config blob. Storage cost is trivial; the cost
that matters is that a :latest digest change stops meaning "something is
different", and :c-<sha> is handed a new manifest to diverge from every
Sunday for no reason.

Corrects the workflow comment (x3) and ci-requirements.md to say what
actually happens. Filed as #3265 with the candidate fixes; the likely one
is a deterministic SOURCE_DATE_EPOCH off the value artifacts.sh already
derives, which would make "same source, same version" into "same source,
same bytes".

The rest of step 4 verified clean on the same run: the guard passed
(HEAD is main (499720d), `git checkout -B main`) — so this runner DOES
evaluate the env context inside `with:` — the tag list was :latest alone
with no :c-<sha>, and the repoint step correctly found nothing to write.
2026-08-30 12:57:06 -04:00
bvandeusen 6663e06aa6 ci: assert the scheduled refresh actually checked out main
CI / extension-version (push) Successful in 5s
CI / lint (push) Successful in 6s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 19s
Build images / sign-extension (push) Successful in 6s
Build images / build-agent (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m48s
BUILD_REF is read through the `env` context inside `with:`, which this
runner is not known to evaluate. `${{ steps.* }}` and `${{ secrets.* }}`
in `with:`/`env:` are proven here; `env` is not, and run 4915's checkout
log (`git checkout -B dev refs/remotes/origin/dev`) cannot tell an
honoured `refs/heads/dev` from an empty value falling back to the same
place — the two are indistinguishable on every path except the one that
matters.

If it does resolve empty, the weekly refresh checks out dev and pushes
its source to :latest, which is production. Every lane stays green and
the first symptom is production running code that was never merged.

So each of the four jobs now asserts its own checkout before doing
anything, gated on `github.event_name` — the `github` context is
demonstrably evaluated in `if:`, so the guard cannot be disabled by the
same uncertainty it covers. A red weekly job is an acceptable outcome;
shipping dev to production is not.
2026-08-29 22:57:41 -04:00
bvandeusen 63e0a423d7 ci: a weekly base-image refresh on the channel tags (milestone 326 step 4)
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 6s
extension / lint (push) Successful in 20s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m50s
Skip-if-exists is keyed on our own source, so an artifact whose source
stops moving stops picking up base-image updates. `agent/` last changed
2026-07-17; every push since has correctly declined to rebuild it, which
also means it will serve that day's nvidia/cuda layers indefinitely.

A `schedule:` trigger, Sunday 06:00 UTC, away from CI-runner's Monday
security sweep so the two are never diagnosing each other.

#3154's blocking open question is dissolved rather than answered. It was
written when the identity was a `r-<revision>` TAG, and asked how the
next ordinary push could avoid repointing :latest back off the refresh.
Milestone 318 replaced that tag with a LABEL, and #3183 made the repoint
step exclude its source tag so the label stays readable. Excluding the
source is what also keeps a refresh from being undone: on the next main
push the reuse check hits, :latest is not rewritten, and the new :c-<sha>
is written FROM the refreshed :latest. To be verified by digest, not by
this argument.

Four decisions, each commented where it lives:

* It builds `main`, not the branch that triggered it. Forgejo registers a
  cron from the default branch — `dev` here — so a scheduled run arrives
  with github.ref on dev, and a refresh of :dev would be refreshing the
  one channel that is rebuilt constantly anyway. The ref is decided once
  in a top-level `env: BUILD_REF` that all four checkouts take. Deriving
  it per job would let the halves disagree: sign-extension would derive
  dev's extension version while build-web bundled main's, and the release
  download would 404 on a version that exists perfectly well.

* It publishes only the channel tag. :c-<sha> for main's HEAD already
  names the bytes that commit built; re-pushing it over refreshed layers
  would break the one tag rule 145 makes immutable, and it is the
  rollback unit — so the breakage would surface on the day somebody
  needed it. The repoint step needs no schedule case: the tag list is the
  channel tag alone, SOURCE is the only entry, it is excluded as always,
  and the step correctly does nothing.

* It bypasses reuse by construction, since it rebuilds the same source
  and fc.revision always matches. Checked in the reuse step beside
  force_build, so one decision still drives both the build and the
  repoint.

* `pull: true`, on the scheduled path only, is the actual mechanism. A
  moved base tag changes the FROM layer's cache key and everything above
  it rebuilds; an unmoved one is satisfied by the registry cache and the
  refresh is a ~13s no-op that republishes nothing. That no-op is the
  point — :latest should change when there is something new in it, not
  every Sunday. The known lag, left deliberately: an apt package update
  while the base tag stands still is not caught, and closing it needs
  no-cache: true, which buys weekly churn for it.
2026-08-29 22:53:48 -04:00
bvandeusen 499720d87e Merge pull request #242 from dev
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 10s
Build images / build-ml (push) Successful in 13s
Build images / build-web (push) Successful in 8s
extension / lint (push) Successful in 22s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 57s
CI / integration (push) Successful in 4m3s
ci: buildx driver, registry layer cache, and a force_build hatch (milestone 326 steps 1-3)
2026-08-29 22:48:47 -04:00
bvandeusenandClaude Opus 5 5e72076298 ci: registry-backed layer cache for all three images (milestone 326 step 2)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 5s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 9s
extension / lint (push) Successful in 20s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m44s
extension / lint (pull_request) Successful in 29s
`cache-from`/`cache-to` on `<image>:buildcache`, `mode=max`, on all three
build steps. Closes the half of the driver change that step 1 left open.

Step 1 measured worse, not better, and that was expected but is worth stating
with numbers. Run 4896, first builds after moving to `docker-container`:

    build-web    3m44s   (cold baseline 2m23s)
    build-ml     3m49s   (cold baseline 3m20s)
    build-agent  11m12s  (cold baseline 9m26s)

The container driver gets a FRESH buildkit instance per job, so it has no
local layer store to fall back on — where the old docker driver at least
reused whatever the runner's dockerd happened to hold. That is why a registry
cache is the only cache this driver can have, and why step 1 on its own is a
regression rather than a win.

`mode=max` so intermediate stages cache too. The agent's two ~150s pip layers
and web's frontend-builder stage are the entire cost, and a min-mode cache
would drop exactly those.

A `:buildcache` tag is not the withdrawn tag scheme returning. Rule 145
narrowed against names NOTHING reads; this one is read by every build that
runs, is one moving ref per image rather than one per build, holds cache blobs
rather than a shippable artifact, and is overwritten in place rather than
accumulating. Closer to `:dev` than to the `:2026.8.28` tags 318 deleted — and
said so in the workflow, so it is not "cleaned up" by a later reader.

Expect the next build to be slower again, once: it is still cold AND now pays
the cache export. The measurement that matters is the one after that.

Scribe #3114.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:56:13 -04:00
bvandeusenandClaude Opus 5 e21c9fdd34 ci: a force_build escape hatch for the path skip-if-exists hides (326 step 3)
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 7s
extension / lint (push) Successful in 18s
CI / integration (push) Successful in 3m47s
`workflow_dispatch` with a `force_build` boolean, honoured inside each of the
three reuse steps.

It exists because skip-if-exists made its own build path untestable. `agent/`
has not changed since 2026-07-17, so the agent build has correctly declined to
run on every push since — which also means #3190, whose whole symptom lives on
that path, cannot be reproduced on demand. Editing build.yml does not force a
build either, and that is deliberate: the workflow is not shipped bytes, so it
is in no artifact's path set, and putting it in one would re-version every
artifact for a comment change.

That is also why this lands before step 2 rather than after. Step 1 moved the
builds onto a container driver and turned attestations off; the claim that
`fc.revision` still reads back cannot be checked until something actually
builds under that driver. Run 4887 confirmed only the cheaper half — `Set up
buildx` succeeded on all three jobs, so the buildkit sibling container does
start against the mounted socket.

Details worth keeping:

* FORCE is checked in the reuse step, not in the build step's `if:`. The
  repoint step keys off `hit` too, and a force that bypassed only the build
  would leave the two disagreeing about what had happened.
* `github.event.inputs`, not the `inputs` context — release.yml already uses
  that form and it is the one this runner is known to evaluate. Read through
  env rather than interpolated into the run block, same as release.yml's TAG.
* One input, not one per artifact. Three booleans is an interface nobody
  remembers.

Scribe #3252, #3249.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:38:29 -04:00
bvandeusenandClaude Opus 5 6b3ec98fa8 ci: build on a real buildx driver, attestations off (milestone 326 step 1)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / build-ml (push) Successful in 33s
CI / backend-lint-and-test (push) Successful in 31s
CI / frontend-build (push) Successful in 29s
Build images / build-agent (push) Successful in 34s
Build images / build-web (push) Successful in 7s
extension / lint (push) Successful in 24s
CI / integration (push) Successful in 3m52s
Adds `docker/setup-buildx-action@v3` to build-web, build-ml and build-agent,
and sets `provenance: false` / `sbom: false` on all three build-push steps.

Two open issues share one root, which is why this is one change:

* #3114 — the agent rebuilds a ~6.3 GB CUDA + torch image whenever the
  runner's local cache is cold, 9m26s against 7s warm. The default `docker`
  driver cannot export a registry cache at all, so the fix is unavailable
  until the driver moves. The cache itself is step 2, deliberately not here.
* #3190 — build-agent goes red AFTER a successful push, `No such image` from
  the local daemon. The leading candidate is the docker driver resolving
  image metadata against a local store a registry-direct push never filled.

The attestation flags are the load-bearing part. On the default driver they
were no-ops; on the container driver, build-push-action@v5 defaults provenance
to TRUE when pushing. Provenance attaches an attestation manifest, that makes
the pushed tag a manifest INDEX, and `.Image.Config.Labels` does not resolve
through an index — so the `fc.revision` label the reuse check reads off the
channel tag would come back `<none>` on every push. Every image would rebuild
forever, every lane would stay green, and the only symptom would be the bill.
Same failure as #3183, through a different door; note #3127 §4 records the
same shape for `platforms:`.

Unverified until CI says otherwise: these jobs run INSIDE a container against
a mounted docker socket, so the buildkit container is a sibling of the job
container rather than a child. That works over a socket mount and has never
been tried on this runner.

The gate is the SECOND dev push, not this one. The images currently published
were built by the old driver, so one `reuse: NOTE ... no readable fc.revision`
is expected now; what must not appear is a second one.

Scribe #3249.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 18:36:35 -04:00
bvandeusen bd24f4e876 Merge pull request #241 from bvandeusen/dev
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 29s
CI / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 18s
Build images / build-web (push) Successful in 1m59s
CI / integration (push) Successful in 3m45s
Milestone 318 step 8 — extension CalVer, and the #3190 mitigation's first main build
2026-08-29 13:53:59 -04:00
bvandeusenandClaude Opus 5 1a941e900b test: encode the extension's AMO rendering exception (milestone 318 step 8)
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 4s
Build images / build-ml (push) Successful in 5s
CI / lint (push) Successful in 2s
Build images / build-web (push) Successful in 3s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m40s
extension / lint (pull_request) Successful in 21s
Missed in 2e01242. This module pinned zero-padded `YYYY.MM.DD.HHMM` for all
four artifacts, which is the family shape and was right until the extension
acquired a documented reason not to use it. Both assertions failed exactly as
written, on the value they were written to catch.

Rather than exempt the extension, the exception is pinned to the constraint
that justifies it:

* `test_version_is_zero_padded_calver` now covers the three padded artifacts.
* A new sibling covers the unpadded one against **AMO's own grammar** —
  `2026.08.29.0201` fails it, so a regression to padding fires immediately.
  Matching only `YYYY.M.D.HHMM` would not: on a date with no leading zeros the
  two renderings are the same string, so a padding regression would sit unseen
  until the first single-digit month, and surface as a burned AMO version
  rather than a red lane.
* `test_version_and_revision_describe_the_same_commit` compares NUMBERS, per
  rule 148's own definition of comparison — so one assertion covers both
  renderings and says the real thing: whatever the padding, the extension must
  denote exactly the value its own commit stamps. Exact-string equality is
  still asserted for everything not in AMO_UNPADDED, so the exception cannot
  quietly spread.

Scribe #3138.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:46:18 -04:00
bvandeusenandClaude Opus 5 2e01242381 feat(extension): derive the version as unpadded CalVer (milestone 318 step 8)
Build images / build-ml (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 22s
extension / lint (push) Successful in 22s
CI / backend-lint-and-test (push) Failing after 33s
Build images / sign-extension (push) Successful in 2m24s
Build images / build-web (push) Successful in 2m38s
CI / integration (push) Successful in 5m15s
`1.0.<minutes since 2020>` -> `YYYY.M.D.HHMM` UTC, from the commit time of
the newest change to a packaged extension file. Same clock and same commit as
before; readable instead of opaque, and the same value the rest of the family
derives.

The hold on this step was two questions about AMO, and Mozilla's own docs
answer both:

    ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$

  1. four all-numeric segments -> ACCEPTED ({0,3} more after the first).
  2. leading zeros              -> REJECTED. A segment is the single digit
     `0` or starts 1-9, so `08` and `0201` are refused. MDN says it in prose
     too: "Non-zero numbers must not include a leading zero."

So the documented fallback applies, extension only: the same numbers rendered
without the family's zero-padding. `2026.08.29.0201` and `2026.8.29.201` are
one value in two renderings — rule 148 defines comparison as numeric per
segment, under which they are equal — so nothing already published is
reordered, and left-padding each segment recovers the family string exactly.
HHMM stays one segment because AMO allows at most four.

The transition is safe in the other direction too: 2026 > 1, so every CalVer
outranks every published 1.0.x. build.yml's downgrade guard confirms it.

Also in scope:

* MAJOR.MINOR is gone. `cmd_major_minor`, `cmd_patch` and VERSION_EPOCH go
  with it, the committed version in manifest.json / package.json is now
  wholly inert, and ci.yml's MAJOR.MINOR-agreement check is retired rather
  than left running beside a fact that stopped existing (rule 22).
* ci.yml's `extension-version` lane now asserts Mozilla's regex verbatim
  instead of a loose `^[0-9]+(\.[0-9]+)*$` — which would have passed the
  padded shape. It also asserts YYYY.M.D.HHMM, because AMO would accept a
  regression to `1.0.<minutes>` while that orders below everything signed
  since. Checking here is the point: AMO 409s on re-signing, so a version it
  rejects is burned and cannot be reused.
* `artifacts.sh version extension` delegates to packaging.sh, so the two
  cannot answer differently. The direction matches the existing one —
  artifacts.sh already asks packaging.sh for the extension's path set.

#3156 is what makes this commit safe to make: packaging.sh is in web's path
set, so the web revision moves with the extension version and build-web
rebuilds instead of republishing an image bundling the previous XPI.

Scribe #3138.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 13:43:30 -04:00
bvandeusen 41f2bec3af fix(ci): the build pushes one tag; the rest are written registry-side (#3190)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-web (push) Successful in 6s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 23s
extension / lint (push) Successful in 28s
CI / backend-lint-and-test (push) Successful in 46s
CI / integration (push) Successful in 3m56s
buildx on this runner pushes the first tag to the registry and then
re-pushes the remaining ones through the DOCKER driver, reading them out
of a local image store that a registry-direct build never populated:

  #27 pushing …/fabledcurator:latest           DONE 15.8s
  #28 pushing …/fabledcurator:c-0e15c44 with docker
  #28 ERROR: tag does not exist: …:c-0e15c44

It is intermittent — build-ml made the identical two-tag push seconds
later in the same run and succeeded — and the consequence is worse than
the red job suggests. `:latest` had already published, so production was
correct while the immutable rollback tag rule 145 requires of every main
push simply did not exist. Nothing else would ever have noticed: a
missing :c-<sha> has no consumer that fails, so it surfaces at the moment
somebody needs to roll back, which is the worst time to learn a rollback
target was never written.

So the build now pushes exactly one ref — the channel's — and the
existing repoint step, which already excluded the source tag and already
ran on every reuse, now runs on the build path too and owns every other
tag. `imagetools create` is a registry-side manifest copy: no local
daemon, nothing that can be absent. This adds no new code path; it puts
the build case onto the one that was already proven.

Chosen over the alternative of asserting each tag resolves after the
build, which would have made the failure loud without making it rarer.

The cost, accepted: `imagetools create` wraps its source in an index, so
:c-<sha> is an index rather than a plain image and fc.revision does not
resolve through it. Nothing reads that label off :c-<sha> — the reuse
check only ever inspects the CHANNEL tag — and the index names the same
manifest, so a pull is byte-identical. The reuse path already produced
:c-<sha> this way; this only makes it uniform.

`build_tags` goes with it — the tag list now has exactly one consumer.
2026-08-29 00:38:26 -04:00
bvandeusen 0e15c44c51 Merge pull request '#240 dev→main: milestone 318 steps 5–7 and 9, plus #3202' from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 49s
CI / extension-version (push) Successful in 3s
CI / integration (push) Successful in 3m57s
Build images / build-ml (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 7s
2026-08-29 00:14:33 -04:00
bvandeusen d38585ed94 docs: true up the tag scheme against what the pipeline publishes (318 step 9)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 4s
Build images / build-web (push) Successful in 5s
CI / frontend-build (push) Successful in 22s
extension / lint (push) Successful in 30s
CI / backend-lint-and-test (push) Successful in 48s
CI / integration (push) Successful in 3m57s
extension / lint (pull_request) Successful in 28s
A doc describing a tag scheme the pipeline stopped using is worse than no
doc — it is a confident wrong answer, and #3159 named it as the drift that
had just bitten rule 148.

build.yml (each edit ×3, once per build job):

* The derived-values block claimed three values including a `tag`
  subcommand that no longer exists, and said "on dev the date tag is
  computed nowhere else". There are two values and no date tag.
* The BASE-IMAGE FRESHNESS paragraph argued from a pin that no longer
  exists — "a date tag has to keep serving the bytes it served
  (fabledcurator:2026.7.17 still resolves to July's image)". Milestone 318
  removed the argument's premise rather than answering it: with no version
  tags there is no immutable name a refresh could contradict, and rule 145
  already allows a moving tag to be republished. Deleted the argument,
  kept a line saying why there is nothing to argue about (#3159's phrasing:
  the workflow becomes obviously correct instead of carefully correct).
* The repoint comment said ":c-<sha> and the date pin". There is no date pin.
* "its set is too narrow — the direction that serves stale bytes on a pin"
  now names the failure that actually exists: the reuse check hits and the
  channel serves a web image bundling the previous XPI (#3156).

README gains a "Versions and tags" section — the three tags, why there is
no fourth, and the fact that with no registry name carrying it, the
instance's own report is the only answer to "which build is this?". Also
fixes two stale claims: three workflows (four since release.yml) and "AMO
signing runs on main only", which has been false since milestone 271
step 6 signed on both channels.

ci-requirements.md was missing milestones 313 and 318 entirely: no
artifacts.sh, no label-keyed reuse, no FC_VERSION. Added those, plus the
#3156/#3202 membership test (can changing this file change the published
bytes — not is it copied in), and corrected packaging.sh's "two consumers"
to three now that artifacts.sh reads it.

docker-compose.yml needed nothing — it names `:dev` directly and never
offered a pin target.

Note #3127 and rule 145's relation notes are updated in Scribe.
2026-08-29 00:09:52 -04:00
bvandeusen a3071a7549 fix(ci): the file that decides FC_VERSION now moves it (#3202)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m5s
Build images / build-ml (push) Successful in 5s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 3m15s
CI / integration (push) Successful in 4m29s
`scripts/artifacts.sh` decides both values the web image carries — the
`fc.revision` label the reuse check compares and the `FC_VERSION` baked into
the image — and was in no artifact's path set. So a change to `cmd_version`
alone left every revision untouched, the reuse check hit, the build was
skipped, and the published image went on reporting the OLD version format,
indefinitely, until some unrelated commit forced a rebuild. Nothing goes red;
the footer just shows a well-formed string of the wrong shape.

Milestone 318 step 5 is the worked instance:

  b3989d0 -> rev=fb2c4d5b80be  ver=2026.8.28.1249
  5771fd5 -> rev=fb2c4d5b80be  ver=2026.08.28.1249
  bce894b -> rev=bce894ba2499  ver=2026.08.28.2208

Same revision across the zero-pad commit, so web's build was skipped. It cost
nothing only by timing: FC_VERSION did not exist until step 6 landed one
commit later.

Web only, and that is the interesting part. Every artifact stamps a revision,
but only web also stamps a version. A revision-only artifact needs no entry
here, because changing how a revision is COMPUTED changes the derived value,
which then disagrees with the label on the published image and forces a
rebuild — the mechanism self-corrects, since it compares against a string
stamped into a real artifact. Nothing compares a version to anything. That
asymmetry is why this was invisible and is now written down in both files.

Named as a file rather than `scripts`: release_notes.py sits beside it and
only reads derived values, so it decides nothing and must not re-version web.

This is #3156 one level up — packaging.sh excluded from the version it
derives — so the guard is generalised rather than duplicated: one DERIVERS
table naming each deriver and the artifacts whose identity it decides. The
too-wide test gains a note saying where the line is, since "copied into no
image" no longer settles it on its own.
2026-08-28 22:01:58 -04:00
bvandeusen b6b9fd8287 ci: a release publishes a changelog, not an image (318 step 7)
CI / extension-version (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 4s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m52s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / frontend-build (push) Successful in 18s
Step 2 took the build consequence away from a `v*` tag — `main` has already
built and published the commit by the time anyone tags it, and rebuilding
would re-push `:c-<sha>`, which rule 145 forbids even when the source
matches. That left the tag with nothing to do at all.

This is the job it has instead. Step 6 put the derived version in the
Settings footer, so an operator can say WHICH build they are running; this
says what is in it that was not in the one they ran last month. Both halves
of one question (note #3127 §5).

The previous release is found by walking ANCESTRY, not by sorting a list.
That is load-bearing here specifically: rule 148 moved the tag shape from
`v26.05.22.0` to `v2026.08.28.2208`, and lexicographically `v2026...` sorts
BEFORE `v26...` — the third character is `0` against `6`. A sorted
implementation would reach back past every new-shape tag to the newest
old-shape one and publish months of commits as "changes since", looking
entirely correct while doing it. `git describe --exclude` is immune to the
shape change, and reachability is the more honest question anyway.

The publisher GETs and PATCHes rather than POSTing and recovering the id
from a 409 — note #3127 §6.7, which is ThoughtSync #2182's bug. A `v*` tag
is created once so the conflict path is rare, but "rare" is how that one
survived to be found somewhere else.

Cross-checks are reported on the release, not enforced. The tag is already
pushed by the time this runs, so failing would leave the operator with a tag,
no release, and a red lane to explain it — while the release is still the
useful object. It says so at the top when the tag names a version the web
image does not report, or when the commit is not on `main` and the `:c-`
rollback refs it lists were never published.

Nothing runs on a schedule and nothing auto-tags on merge. Release tags are
bookmarks (note #3127 §0); FC went twelve weeks without one and nothing was
wrong.

Also here:
- `scripts/` joins the ruff lane. release_notes.py runs only on a tag push,
  so a syntax error there would otherwise surface at the one moment nobody
  wants to be debugging a workflow.
- version.spec.js reads the workflow directory instead of listing three
  files by hand. Its own comment says the assertion should survive consumers
  coming and going; the hardcoded list was the part that could not, and
  release.yml would have joined the directory without joining the check.

Tests build a synthetic history spanning the tag-shape change rather than
leaning on this repo's tags, so the span assertion holds whether or not a
checkout brought the tags along — a span test that quietly skips is worse
than one that fails.
2026-08-28 20:57:25 -04:00
bvandeusen bce894ba24 feat(settings): the instance reports which build it is (318 step 6)
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / build-agent (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 2m50s
Build images / build-web (push) Successful in 2m49s
CI / integration (push) Successful in 3m52s
A dim line at the foot of Settings: `FabledCurator 2026.08.28.1249 · dev`.

This is no longer a convenience. Milestone 318 stopped publishing version
image tags, so an instance's own report is the ONLY answer to "which build is
this?" — there is no registry name left to check it against. Note #3127 §5
says it directly: a wrong answer here has no second source to contradict it.

Three states, kept distinct because collapsing any two of them lies:

  not asked yet         render nothing
  asked, no version     render "unknown"
  asked, has a version  render it

A blank footer reads as "no version", which is a different claim from "I
cannot say". And a failed health call deliberately does NOT mark the build
loaded — a network blip says nothing about the image, and presenting it as
"unknown" would look like a defective build.

Carried on /api/health rather than a new route: it answers at the same cost
(two module constants, no I/O) and TopNav already fetches it app-wide, so a
separate endpoint would mean a second request for two strings.

Both fields are OMITTED when unset rather than sent empty. Absence already
means "cannot say" — an image predating the field says exactly that by not
having the key — so a second spelling would make every reader special-case
it. The pre-existing test asserting the body is EXACTLY {"status": "ok"} is
what keeps a well-meaning `or ""` default from creeping in.

FC_CHANNEL now has one definition. It was read from the environment in
extension.py and would have been read again here; the new build_info module
holds both, and extension.py binds it as a module-level name so existing
tests monkeypatch it exactly as before. Separate from config.py on purpose:
those are operator settings meant to be changed, these describe the artifact.

Channel sits beside the version, never inside it (rule 149), asserted from
both ends. A `-dev` suffix would read as a 0 segment to the extension's
parseInt comparator and make every dev build compare equal — #2993 exactly.

Not hidden, per the operator and §7: the JS bundle and asset hashes
fingerprint the build anyway, and "I'm on 2026.08.28.1249" is the single most
useful line in a bug report.
2026-08-28 18:08:31 -04:00
bvandeusen 5771fd5770 build: zero-pad the derived version to YYYY.MM.DD.HHMM (318 step 5)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / integration (push) Successful in 3m50s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 4s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 32s
`2026.8.28.1249` becomes `2026.08.28.1249`. Note #3127 §1 and rule 148 both
specify the padded form.

The old reasoning was that each segment should read as a plain integer, and
it never held — comparison strips leading zeros on parse anyway, which the
same paragraph said. What stripping actually bought was this project emitting
`2026.8.28.1432` while a sibling emitted `2026.08.28.1432`: two shapes one
character apart, which is the hard kind of difference to notice. Two
obviously different formats would be safer than two nearly identical ones,
and identical is safer still.

Nothing already published is reordered: comparison is numeric per dot-segment,
so `08` and `8` are equal.

strip0 goes, and with it three of the four git calls per version — git's
format-local takes the whole format string, and splitting it into pieces only
ever existed to strip the padding between them.

It also fixes a real edge the old helper mangled. A commit at 03:22 UTC
derived `322` for its HHMM field, silently turning a four-digit field into
three; it now derives `0322`. Verified against a real commit rather than
reasoned about.

Checked before relying on it, since step 8 feeds this to Firefox: the
extension's comparator is `parseInt(n, 10)` with an explicit radix, so `08`
reads as 8 and there is no octal hazard (rule 150).

Two tests added. One pins the padded shape — the only thing keeping the
family's projects emitting one string is an assertion that they do. The other
asserts version and revision describe the same commit: they are derived
independently, and a divergence would mean an instance naming one commit
while carrying another's bytes, which is unfalsifiable from outside because
both values still look well-formed.
2026-08-28 16:35:26 -04:00
bvandeusen 6d98dfc0ec Merge pull request 'Stop minting identifiers nothing reads: moving channel tags, :c-&lt;sha&gt; on main, and a label-keyed build cache' (#239) from dev into main
Build images / build-agent (push) Successful in 9m23s
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 34s
CI / frontend-build (push) Successful in 23s
Build images / build-ml (push) Successful in 3m1s
extension / lint (push) Successful in 21s
CI / integration (push) Successful in 4m11s
Build images / build-web (push) Successful in 3m21s
2026-08-28 15:14:35 -04:00
bvandeusen b3989d0224 fix(ci): the repoint was destroying the label it depends on
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 3s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 42s
Build images / build-agent (push) Failing after 2m33s
CI / integration (push) Successful in 3m44s
extension / lint (pull_request) Successful in 25s
Reuse worked exactly once per artifact, then every later push rebuilt at full
price. Nothing failed and nothing went red — the savings simply evaporated.

`imagetools create` wraps its source manifest in an INDEX. The repoint step
passed the channel tag as both source and target, so after any reuse :dev
stopped being a plain image, `.Image.Config.Labels` no longer resolved
through it, and fc.revision read as absent on the next push.

Observed across three runs rather than reasoned about: run 4749 read
fc.revision=a7e626a67a79 off fabledcurator-ml:dev and skipped the build; run
4751 read <none> off the same tag and rebuilt. The only thing to touch it in
between was 4749's own repoint. The agent hit in 4751 precisely because its
:dev had last been written by a real build, not by a repoint — which is the
control case.

Milestone 313's r-<rev> design was immune without anyone noticing why: the
source (the identity tag) was never one of the targets. Step 3 made the
channel tag both, and inherited a bug the earlier shape had avoided by
accident.

Fix: exclude the source from the target list, so the channel tag is only ever
written by a real build and stays a plain readable image. On dev that leaves
nothing to do, which is correct — the hit already established that :dev
points at the right content. On main it leaves :c-<sha>, which rule 145
requires of every main push whether or not a build ran.

Also added a note the reuse step prints when a channel tag exists but carries
no readable label. That is expected exactly once per artifact during the
migration; if it appears on every push, the tag is being index-wrapped again
and reuse is dead. This class of failure — correct behaviour, quietly worth
less than it reads — is the third one this milestone has turned up, and it is
the one that does not announce itself.
2026-08-28 14:55:49 -04:00
bvandeusen cd0b0ff04a ci: publish :latest + :c-<sha> on main, :dev on dev — nothing else (318 step 4)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
Build images / build-agent (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
extension / lint (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 30s
Build images / build-web (push) Successful in 1m57s
Build images / build-ml (push) Successful in 2m35s
CI / integration (push) Successful in 3m46s
The narrowing itself. Rule 145, narrowed 2026-08-28 once it was verified that
nothing pins: "a third name for the same thing is upkeep for a model we do
not run."

Gone:

  :2026.8.28  the per-artifact date tag from milestone 313 step 3. It shipped,
              it was verified on both branches, and its premise is gone. This
              is a withdrawal, not a correction — nothing about it was wrong.
  :main       a second moving name for whatever :latest already pointed at,
              justified by nothing but symmetry with :dev.

Kept, and note the inversion: :c-<sha> on main. Milestone 313 step 6 planned
to retire it on "haven't used, not important" — amended rule 145 makes it the
rollback unit and, with version tags gone, the only immutable identifier
left. #3139 is cancelled as superseded rather than quietly dropped.

No retention job is built. One already runs (#3157): a nightly Gitea
package_cleanup_rule with keep_count=10. Two consequences worth writing down.
Its keep_pattern protects any dotted-numeric tag, so the date tags this
commit stops minting would have been exempt from retention and accumulated
without bound — silently, in the direction of unbounded growth. That trap is
now dissolved rather than needing the pattern change #3139 planned. And
:c-<sha> IS prunable under that pattern, so the honest statement is that it
is the rollback unit for roughly the last 10 builds, not for every main
commit ever.

cmd_tag goes with its last consumer rather than being left as a dead
subcommand for a later step to notice. The report step logs version and
revision, which is the same information — the date is the first three fields
of the version.

Docs needed no change: every consumer already tracks a moving tag
(docker-compose.yml on :dev, agent/README.md on :latest), which is the
evidence the whole stance rests on.
2026-08-28 14:45:11 -04:00
bvandeusen 454eb3f973 fix(tests): artifact identity tests talk to artifacts.sh, not to a sibling
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 5s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
Build images / build-web (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 33s
Build images / build-agent (push) Successful in 3m50s
CI / integration (push) Successful in 3m51s
Run 4746 failed lint and pytest on the same cause: `from test_artifact_paths
import ROOT, declared_paths`. No other test module in this repo imports
another, so that was a new convention introduced for no gain — and the wrong
one, since `tests/` is a package and the bare name does not resolve.

Everything now goes through `artifacts.sh`, which is the interface build.yml
actually calls. The tests exercise the contract rather than a Python
re-implementation of it, and the duplicate `declared_paths` helper is gone
rather than copied.

Two real defects found while fixing it:

The newest commit is now computed by committer TIME, matching what
artifacts.sh means. It was `git log -1`, whose default order is
reverse-chronological only within topological constraints — so on a merged
history it can name a different commit than the newest timestamp does. Both
agree on this repo today (verified across all four artifacts), which is
exactly what makes it a flake waiting for the branch shape that separates
them.

The third test asserted the same invariant as the first in different words.
Removed rather than left as apparent coverage.
2026-08-28 14:42:42 -04:00
bvandeusen 7e065fed70 ci: key the reuse check on an image label, not a tag (318 step 3)
CI / extension-version (push) Successful in 4s
CI / lint (push) Failing after 4s
Build images / sign-extension (push) Successful in 4s
CI / backend-lint-and-test (push) Failing after 13s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 22s
CI / integration (push) Failing after 2m24s
Build images / build-web (push) Successful in 2m44s
Build images / build-ml (push) Successful in 3m13s
Build images / build-agent (push) Successful in 8m56s
The shadow (dee93fa, run 4732) answered the gate: `imagetools inspect
--format` reads `.Image.Config.Labels` against this registry on buildx
v0.36.1. So the reuse check now asks the moving channel tag whether the image
it already points at carries this commit's `fc.revision`, and the r-<rev>
identity tags stop being published.

Three things this removes rather than manages:

A name minted per build that one thing read. Rule 145's narrowing is aimed
exactly there — "a third name for the same thing is upkeep for a model we do
not run."

The -main/-dev qualifier, and the CHANNELLED list behind it. Which tag you
inspect IS the channel, so the distinction has nowhere to live. cmd_identity
goes with it.

A silent expiry nobody wrote down. r-<rev> matches no branch of the
registry's keep_pattern (#3157), so identity tags were prunable past the
newest 10 — a pruned one costs a rebuild, in the safe direction and entirely
invisibly. A label rides inside a tag that has to exist anyway.

It also dissolves #3154 instead of deferring it: a scheduled base refresh
rebuilds :latest with the same revision label, the next unrelated push sees a
match and skips, and the refreshed base survives. Under the tag scheme that
push repointed :latest back to the older base.

The measured detail that shapes the code: a missing label returns an EMPTY
STRING and exits 0. Branching on the exit code would read "no label yet" as
success and skip a build that was needed. So it compares values, and every
uncertain case — absent label, unreachable tag, older image — lands as empty,
never equals a 12-char revision, and falls through to a build.

Reading the specific key matters too. The map carries the base image's labels,
and org.opencontainers.image.version sits right beside ours reading 24.04 on
the agent — a plausible-looking wrong answer.

Expect every artifact to rebuild once on this push: nothing carries a label
yet and it cannot be backfilled, since the reuse path copies a manifest and
config labels are not manifest annotations. One rebuild per artifact, ever,
self-healing after.

test_artifact_identity.py is rewritten around what is now load-bearing. The
CHANNELLED drift test had nothing left to guard; in its place the revision is
asked of git directly, so the file fails if the derivation ever stops being
"the commit this artifact's own shipped files last changed in".
2026-08-28 14:37:36 -04:00
bvandeusen dee93faa37 ci: shadow the label-based reuse key before trusting it (318 step 3)
CI / lint (push) Successful in 4s
Build images / sign-extension (push) Successful in 5s
CI / extension-version (push) Successful in 5s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m50s
Additive and inert. Every build now stamps `fc.revision` as an image label,
and a diagnostic step tries to read it back off the channel tag. Nothing
downstream reads either yet — the reuse check still keys on the r-<rev> tag.

The question step 3 has to answer before committing: can the reuse check be
keyed on a LABEL read off the channel tag instead of on a separate tag? If
it can, nothing is minted per build, the -main/-dev identity split
disappears with CHANNELLED, and #3154's base-refresh clobber stops existing
rather than needing a workaround. It also closes a defect found in what
already shipped: r-<rev> tags do not match the registry's keep_pattern
(#3157), so they are prunable, and a pruned identity tag silently costs a
rebuild. A label cannot be pruned — it rides inside a tag that must exist.

Three probes rather than one, because `--format` templating over .Image
varies by buildx version and one round trip per guess is a poor trade.

Two questions, answered at different times, and conflating them would
abandon a working design:

1. Do the template mechanics work here? Answered on the first run — if the
   probes print a labels map, even an empty one, `--format` resolves on this
   buildx and this registry. That is the real gate.
2. Does a value round-trip? Only once an artifact genuinely rebuilds. This
   push touches build.yml, which is in no path set, so all three artifacts
   reuse and stamp nothing. Expect no values this run.

That second point is the migration path rather than a defect: under the real
mechanism "no label" reads as a MISS, so each artifact rebuilds once and is
stamped from then on — one extra build per artifact, ever, in the safe
direction. `imagetools create` cannot backfill it, since it copies a
manifest and config labels are not manifest annotations.
2026-08-28 14:00:09 -04:00
bvandeusen d9aa5aa832 ci: a release names a build, it does not make one (milestone 318 step 2)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
Build images / build-web (push) Successful in 4s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 26s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m50s
Removes the `v*` image-build trigger. A release tag names a commit `main`
already built and published; rebuilding it produced the same source under
the same names and RE-PUSHED `:c-<sha>` — which rule 145 forbids even when
the bytes match, because image configs carry timestamps and "same source"
does not mean "same manifest". The tag build was publishing nothing new and
violating an immutability rule to do it.

Three constructs go with it, all of which existed only to manage that build:

The 10-minute XPI polling loop. A release cut fired the tag build and the
main build together; the tag build skipped sign-extension and raced straight
to the download, losing every time (operator-flagged 2026-05-27 after
v26.05.27.0). Polling was the fix for a build that should not have run. It is
now a single fetch, and a 404 means the two jobs derived different versions
for one commit — which should fail loudly rather than be slept through.

The IS_TAG_PUSH carve-out from milestone 313 step 4, which stopped a tag
build from claiming an identity tag it would have re-pointed at fresh bytes.

build-web's `always() && (success || skipped)` gate, which existed to let a
SKIPPED sign-extension through on a tag push while still blocking a failed
one. sign-extension now always runs, so a plain `needs` gives exactly the
wanted behaviour. Its own branch condition goes too: main and dev are the
only triggers, so a gate naming both matched everything, and a condition
that is always true reads as though some path avoids it.

Releases still happen — rule 148 is untouched and tags are still cut on
explicit request per rule 2. They stop building images and start carrying a
changelog (step 7).

Net 115 lines deleted, 73 added, most of that comments explaining races that
can no longer occur. Nothing in the repo referenced the tag build, so no doc
changes were needed; the broader doc pass is step 9.
2026-08-28 13:49:45 -04:00
bvandeusen 6c76f08b69 Merge pull request 'Per-artifact commit-derived versions: pinnable date tags, and no rebuild when nothing changed' (#238) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 5s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 6s
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m42s
2026-08-28 10:14:15 -04:00
bvandeusen fb2c4d5b80 fix(extension): packaging.sh decides the version, so it must move the version
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
Build images / build-ml (push) Successful in 4s
Build images / build-agent (push) Successful in 5s
CI / frontend-build (push) Successful in 18s
extension / lint (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
Build images / sign-extension (push) Successful in 1m41s
Build images / build-web (push) Successful in 11s
CI / integration (push) Successful in 3m42s
extension / lint (pull_request) Successful in 21s
`packaging.sh pathspec` excluded `extension/scripts/**` — the same list
web-ext ignores. But the two lists answer different questions, and this is
the one place they disagree.

packaging.sh is not packaged into the XPI. It does decide the version
string, and build.yml stamps that string into the manifest.json that IS
packaged. Changing how the version is computed therefore changes the shipped
bytes, and the derivation has to see it.

Harmless while every push rebuilt the web image. Step 4 made the rebuild
conditional on the derived revision moving, which turns it into a silent
failure: a packaging.sh change yields a NEW version, so sign-extension misses
its ext-<version> cache and signs — while build-web sees an unmoved revision,
reuses the published image and ships the OLD XPI. One orphaned AMO signature,
and an instance serving code the registry calls current. Found while checking
the ground under step 5, which changes the version format and is exactly the
commit that would have hit it.

Split the list rather than widening the shared one: NOT_VERSION_RELEVANT
drives the pathspec, NOT_PACKAGED_TRACKED still drives web-ext's ignore
list, and scripts/ stays out of the XPI. The two directions are not
symmetric, which is why the version list is the narrower one — too wide
costs a re-sign and a rebuild for a change that ships nothing new, too
narrow serves stale bytes and says nothing.

No version churn: the last packaging.sh commit predates the current
extension revision, so the derived version is unchanged at 1.0.3500147 and
web's revision stays a7e626a67a.

Both suites now pin the disagreement from their own side, because the
tempting fix for either half is to make the lists one again — version.spec.js
asserts the pathspec does NOT exclude scripts while the ignore list still
does, and test_artifact_paths.py asserts packaging.sh is inside the extension
and web path sets.
2026-08-28 08:49:06 -04:00
bvandeusen 609bc82acc ci: reuse the published image instead of rebuilding it (milestone 313 step 4)
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
extension / lint (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m43s
Before building, each job asks the registry whether this artifact's content
is already published. On a hit it skips the build entirely and repoints the
channel and date tags at the existing manifest with `imagetools create` —
registry-side, no layer transfer, seconds. This is the step that stops a
push touching only `agent/` from rebuilding web and ml, and stops a merge to
main rebuilding what dev already built.

The question is asked with a new `artifacts.sh identity`, not with the date
tag: the date tag is day-precise and last-one-wins, so two different builds
share it and it cannot answer "is this content published?". The commit sha
would move on every push and never hit, which is the redundant rebuild being
removed. The revision does both jobs — content-unique, and stable across
pushes that did not touch the artifact.

Identity is channel-qualified for web and only for web, because web is the
only image that takes a build-arg: FC_CHANNEL is baked in and reported by
/api/extension/manifest, so its dev and main builds of one revision are
genuinely different images. ml and agent take none, which is what lets a
merge reuse dev's build rather than rebuilding the agent's CUDA image to
produce bytes that already exist. tests/test_artifact_identity.py reads the
Dockerfiles and fails if that list drifts from the ARG declarations, in
either direction — collapsing the channels ships an instance that reports
the wrong one, and splitting them needlessly rebuilds every merge.

Failure direction is deliberate: an inspect that errors for any reason reads
as a miss and the build runs. Only a real 200 skips one.

A tag-push never claims the identity. It rebuilds a revision main already
published, and image configs are not bit-reproducible, so re-pushing
r-<rev> would point an immutable tag at fresh bytes — rule 145's exact
prohibition. It publishes only its own :v... label and otherwise reuses.

Base-image freshness, decided rather than left implicit: an artifact whose
source stops moving stops picking up base updates under its pinned tag. That
is what a pin means, and rule 145 already says the refresh belongs on the
moving tag instead. Filed as #3154 rather than folded in here, because the
naive version regresses :latest on the next unrelated push.

ci.yml's backend lane gains fetch-depth: 0 — the new tests derive real
revisions, and on a depth-1 clone that derivation returns the tip sha or
fails, so the lane would go green while asserting nothing.

The three build jobs' shadow steps are renamed and re-commented: those
values stopped being informational at step 3, and a step captioned "nothing
reads this" beside steps that do is worse than no caption.
2026-08-28 08:24:58 -04:00
bvandeusen 7a20c55441 ci: publish a per-artifact date tag on main builds (milestone 313 step 3)
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 6s
Build images / build-web (push) Successful in 5s
extension / lint (push) Successful in 17s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m44s
Each main build now also publishes <image>:YYYY.M.D — the date of the
commit that artifact's shipped files last changed in. Purely additive;
:main, :latest, :c-<sha> and the dev path are untouched, so reverting this
commit reverts the behaviour.

    main push -> :main, :latest, :c-<sha>, :2026.8.27
    dev push  -> :dev

Per artifact, so an image whose files did not change keeps the tag it
already had. On this commit the agent reads 2026.7.17 while web and ml
read 2026.8.27 — six weeks apart, from one push. Step 4 turns that into
not rebuilding it.

Day precision, and a second main build the same day replaces the first.
Operator's call, and the reasoning is theirs: same-day work is not
something worth pinning. A rollback goes to a day, not to the fourth merge
of a Tuesday afternoon. It also makes retention mean "the last N days"
rather than "the last N pushes".

CALVER is computed inside the main branch rather than at the top of the
step, and hard-fails when empty. There is no `set -e` here, so an
unconditional assignment that failed would have left it empty and published
the tag `fabledcurator:` — an invalid name, from a step that still reported
success. It is also simply unused on the dev and tag paths.

Fixed a stale comment while in this block rather than leaving it for step
7: it claimed release tags carry "no `.N` per family release-posture rule".
Rule 148 was amended on 2026-08-24 to REQUIRE the suffix, after the ban
caused a same-day tag to be retargeted and a release deleted to make room
(note 2813). FC's own tags already carry suffixes; only the comment was
asserting the superseded rule.

Verified before pushing: the derivation holds across 200 commits of real
history — a derived revision always touches its own path set, the version
never decreases along any parent->child edge, and web tracks all three
extension-only commits in the log. That last one is the direction that
would serve stale bytes on a pin.
2026-08-27 21:56:52 -04:00
bvandeusen 0c43fa3eb2 ci: shadow the per-artifact derived versions (milestone 313 step 2)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
extension / lint (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m44s
Every build job now logs the tag, version and revision its artifact would
get. Nothing reads them; no `set -e`, and each derivation falls back to
UNAVAILABLE, so a broken script cannot fail a build. Same discipline as
milestone 271 step 2, which is what made that cutover safe to do in one
commit.

Also fixes a landmine the plan named but had not checked: build-ml and
build-agent were checking out at depth 1. Both now use fetch-depth: 0.

That mattered more than it looks. A depth-1 clone sees one commit, so
`git log HEAD -- <shipped paths>` either returns that commit's timestamp —
plausible, and wrong — or returns nothing. For build-ml on this push it
would have returned today's date, because HEAD touches backend/, and
nothing downstream would have questioned it. For build-agent it would have
returned nothing at all, since no single commit here touches agent/, and
artifacts.sh exits non-zero rather than guessing. One direction is silent
and one is loud; only the loud one was ever going to get noticed.

What to read from the shadow lines over the next few pushes, in order of
how badly each would bite:

  * a push touching the extension must move BOTH the extension and web,
    because build-web bakes the XPI in. If web does not move, its path set
    is too narrow and a pinned web image will serve an extension it does
    not name.
  * a push touching only agent/ must leave web and ml still. If they move,
    their sets are too wide and they will rebuild for changes they do not
    ship.
  * a docs-only push must move nothing.
  * dev and main must derive the same values for the same source.

Step 3 only lets these values name a tag once those hold.
2026-08-27 21:41:14 -04:00
bvandeusen cf06c81db9 build: one definition per artifact of what it ships (milestone 313 step 1)
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / frontend-build (push) Successful in 41s
CI / backend-lint-and-test (push) Successful in 2m4s
CI / integration (push) Successful in 4m15s
Build images / build-web (push) Successful in 4m50s
Build images / build-ml (push) Successful in 5m43s
Build images / build-agent (push) Successful in 10m45s
scripts/artifacts.sh generalises what packaging.sh established for the
extension: four published artifacts, four path sets, four independent
versions derived from the newest commit touching each set.

Measured on this commit, and this is the point of the whole thing:

  web        tag=2026.8.27  version=2026.8.27.1547  rev=a7e626a
  ml         tag=2026.8.27  version=2026.8.27.1547  rev=a7e626a
  agent      tag=2026.7.17  version=2026.7.17.1657  rev=57e5243
  extension  tag=2026.8.27  version=2026.8.27.1547  rev=a7e626a

The agent is six weeks behind because agent/fc_agent has not changed since
57e5243. Today it rebuilds and re-tags on every push regardless; from step
4 it will not.

Three outputs, because they answer different questions and conflating them
is how this goes wrong:

  tag       YYYY.M.D        the published image tag. Day precision, per the
                            operator: same-day work is not worth pinning, so
                            a second build that day replaces the first.
  version   YYYY.M.D.HHMM   the ordering key. The extension needs this and
                            cannot use `tag`: Firefox compares it to decide
                            whether an update exists, so two same-day builds
                            must be distinguishable or the second hits the
                            ext-<version> cache and ships stale bytes. That
                            is issue #2397's failure mode exactly.
  revision  <sha>           content identity. Because `tag` is only
                            day-precise, "does this tag already exist" cannot
                            decide whether a build can be skipped — two
                            different builds legitimately share a tag. Step 4
                            keys on this instead.

Path sets read from the Dockerfiles rather than guessed. Notable calls:

  - web includes the extension's packaged set, because build.yml bakes the
    signed XPI into frontend/public/extension/ before the docker build. Miss
    that and :latest serves a NEW extension under an unchanged web version.
  - web excludes frontend/test: vite builds from src/, index.html and
    public/, so a spec change lands in the builder layer but never in dist.
  - agent is agent/Dockerfile + agent/requirements.txt + agent/fc_agent,
    NOT agent/. README.md, ruff.toml and docker-compose.yml sit in that
    directory and never reach the image.
  - every set includes its own Dockerfile and requirements: a base-image
    bump changes the artifact as surely as a source edit does.
  - the extension's set is read from packaging.sh, not restated. One
    definition, per #2397.

tests/test_artifact_paths.py guards both directions of being wrong, since
both are silent. Too narrow — a COPY'd file missing from the set — means the
version does not move when the content does, and a pin serves stale bytes.
Too wide means re-versioning for a change the artifact does not ship. The
test parses each Dockerfile's COPY lines and compares them against the
declaration, so adding a COPY without updating the set fails the lane.

No workflow reads any of this yet. Step 2 shadows it.
2026-08-27 21:36:17 -04:00
bvandeusen 7b1019ba82 Merge pull request 'Extension channels: dev and main each carry their own signed extension' (#237) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
extension / lint (push) Successful in 23s
CI / extension-version (push) Successful in 3s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 3m12s
CI / integration (push) Successful in 5m29s
Build images / build-agent (push) Successful in 9m54s
2026-08-27 12:49:39 -04:00
bvandeusen 0db38cc111 ci: log in to the registry with the docker CLI, not docker/login-action
Build images / sign-extension (push) Successful in 4s
CI / frontend-build (push) Successful in 28s
CI / backend-lint-and-test (push) Successful in 32s
CI / lint (push) Successful in 6s
CI / extension-version (push) Successful in 6s
extension / lint (push) Successful in 45s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m59s
CI / integration (push) Successful in 4m9s
Build images / build-agent (push) Failing after 10m58s
extension / lint (pull_request) Successful in 27s
build-ml failed at the login step twice on a7e626a, five seconds in, with
MODULE_NOT_FOUND on the action's own dist/index.js. Not the token — the
secret resolved to *** and the action never ran far enough to use it.

The cause is a race in act_runner's shared action cache, not corruption.
A remote action is cached at one /root/.cache/act/<hash> per runner, and
build-web, build-ml and build-agent all start in the same second and all
want docker/login-action@v3. One job re-clones that directory — emptying
and repopulating it — while another walks it to copy into its container,
and the walker lstat()s a file that just vanished. The two failures named
DIFFERENT missing files, eslint.config.mjs then jest.config.ts, which is
what rules out a bad cache entry and points at the race: a dangling entry
would name the same file every time.

Re-running does not help, because the re-run starts the three jobs
simultaneously again. It reproduced immediately.

Dropping the action removes FC from that race for this step. Logging in is
one command, the docker CLI is already in the CI image per
ci-requirements.md, and the same reasoning as family rule 5 applies: a
marketplace action buys nothing when the tool is baked into the image the
workflow already selected. Password on stdin, never as an argument — an
argument lands in the process table and draws docker's own deprecation
warning.

This narrows the exposure rather than closing it. All three jobs also
share docker/build-push-action@v5 and can race on it the same way; that
one has not lost yet, and replacing it means hand-rolling buildx
invocation including the build-args and provenance handling, which is a
bigger change than this failure justifies. Recorded on #3118.

Live consequence being cleared: fabledcurator-ml:dev was left a commit
behind fabledcurator:dev, which is the stale-pairing trap the trigger
comment on 239b1ed warns about.
2026-08-27 12:08:46 -04:00
bvandeusen a7e626a67a feat(extension): report the channel beside the version (step 7)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 5s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
extension / lint (push) Successful in 28s
CI / integration (push) Successful in 3m52s
Build images / sign-extension (push) Successful in 4s
Build images / build-ml (push) Failing after 5s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 2m4s
Closes the half of the ask the signing work didn't: a way to tell a dev
build from a main one. FC_CHANNEL is baked into the web image at build
time and /api/extension/manifest reports it as its own key, next to
version — the popup banner, the toolbar tooltip and the Settings card all
name it.

Beside the version, never inside it. A `1.0.3499884-dev` suffix is the
obvious shortcut and it is the exact failure this design comes from:
versionIsNewer parses each dotted segment with parseInt, so a suffixed
segment reads as 0, every dev build compares equal to every other, and
"no update available" stops being distinguishable from "I cannot read this
version". The comparator already degrades rather than discarding (rule
150), which is a reason not to NEED the suffix, not a licence to add one.
Two tests hold the line — one backend, asserting version and channel are
separate keys; one frontend, asserting the rendered version text stays the
bare derived number.

Optional on the read side, and absent rather than defaulted. An image
built before this field says nothing by not having the key; an image built
without a channel now says nothing the same way, so there is one absence
to handle instead of a second spelling of "unknown". Every reader drops
the label entirely when it is missing and reads exactly as it did before.
Reported verbatim rather than validated against {dev, main}: if an image
declares something else, showing what it claims helps whoever is debugging
more than dropping it would.

FC_CHANNEL is declared LAST in the Dockerfile. An ARG invalidates every
layer below it, and this is the one value that differs between the dev and
main builds of identical source — earlier, and the two channels could
never share a cached pip install. A tag push counts as main: a vYY.MM.DD
tag is cut from main, so that image is a main-channel artifact wearing an
immutable name.

No channel switcher, deliberately. background.js:34 already records that
Firefox's static update_url cannot apply, because every FC instance is a
different host — so the extension asks its configured backend, and the
channel IS the instance it points at. Switching is repointing apiUrl and
reinstalling from that host. A separate setting would contradict each
server build shipping its own extension.

This commit touches packaged extension files, so it moves the derived
version and will sign a new one via AMO — the first push to exercise the
extension-changed path from dev end to end.
2026-08-27 11:47:30 -04:00
bvandeusen fe48e77821 ci(extension): retire the manual-bump guard, true up the docs (step 5)
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.
2026-08-27 11:29:44 -04:00
bvandeusen 0d204e6837 Merge pull request 'Extension packaging: one source of truth, shadow-mode versioning, real XPI verification' (#235) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 34s
extension / lint (push) Successful in 40s
CI / backend-lint-and-test (push) Successful in 2m1s
CI / integration (push) Successful in 4m3s
Build images / build-web (push) Successful in 4m21s
Build images / build-ml (push) Successful in 5m39s
Build images / build-agent (push) Successful in 12m49s
2026-08-03 20:23:27 -04:00
bvandeusen 8300029741 Merge pull request 'Extension: fix credential-push 405, guard the publish path, add a unit suite' (#234) from dev into main
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 32s
extension / lint (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 1m14s
Build images / sign-extension (push) Successful in 2m23s
Build images / build-ml (push) Successful in 3m53s
CI / integration (push) Successful in 3m56s
Build images / build-web (push) Successful in 2m34s
Build images / build-agent (push) Successful in 13m33s
2026-08-03 08:29:09 -04:00
bvandeusen b5b437ca80 Merge pull request 'feat(agent): idle-unload GPU models to free VRAM when the queue is idle' (#233) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 34s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 25s
Build images / build-web (push) Successful in 3m20s
Build images / build-ml (push) Successful in 4m29s
CI / integration (push) Successful in 4m26s
Build images / build-agent (push) Successful in 11m55s
2026-07-17 13:07:15 -04:00
bvandeusen ce0dac3524 Merge pull request 'DRY + stability pass — extension, ML/settings backend, frontend cards (#161)' (#232) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 12s
extension / lint (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 50s
CI / integration (push) Successful in 3m47s
2026-07-13 23:08:18 -04:00
bvandeusen 9b5ec86222 Merge pull request 'feat(extension): in-app update prompt + v1.0.9 (#1489)' (#231) from dev into main
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
extension / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
Build images / sign-extension (push) Successful in 3m34s
Build images / build-web (push) Successful in 8s
CI / integration (push) Successful in 3m50s
2026-07-13 18:32:31 -04:00
bvandeusen 89c83ee5de Merge pull request 'fix(extension): Add-to-FC button on subscribed Patreon creators + v1.0.8 (#1485)' (#230) from dev into main
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
extension / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m46s
Build images / sign-extension (push) Successful in 5m8s
Build images / build-web (push) Successful in 10s
2026-07-13 17:56:32 -04:00
bvandeusen d3192f1843 Merge pull request 'chore(deps): web-ext 8→10 (Renovate batch 5) (#1450)' (#229) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
extension / lint (push) Successful in 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m48s
2026-07-13 16:36:26 -04:00
bvandeusen 5a5694f200 Merge pull request 'fix(ui): softer chrome-gradient falloff + segmented media toggle (#1478)' (#228) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m42s
2026-07-13 15:55:02 -04:00
bvandeusen bb1a938cc0 Merge pull request 'fix(ui): measure real nav height (--fc-nav-h) — Explore fits on 1080p, no breadcrumb tuck (#1481)' (#227) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m50s
2026-07-13 15:32:25 -04:00
bvandeusen fc0293029d Merge pull request 'fix(ui): Vuetify-4 review polish — active-tab indicator + one continuous chrome gradient' (#226) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m44s
2026-07-13 14:47:59 -04:00
bvandeusen 7d1c701b67 Merge pull request 'fix(ui): remove redundant app-shell top gap (#1480)' (#225) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 7s
2026-07-13 14:12:41 -04:00
bvandeusen b638382cd5 Merge pull request 'Ship: UI framework batch 4 — Vuetify 4 + vue-router 5 + pinia 3 (#1449)' (#224) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 28s
CI / frontend-build (push) Successful in 34s
CI / backend-lint-and-test (push) Successful in 53s
CI / integration (push) Successful in 3m46s
2026-07-13 13:40:25 -04:00
bvandeusen 17903068b4 Merge pull request 'Ship: system-tag refactor + soft WIP tier + Explore reach (#1464, #1474, #1476)' (#223) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-13 12:13:19 -04:00
bvandeusen 31d400ab0a Merge pull request 'Ship: title-based WIP auto-tagging (#1458)' (#222) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m50s
2026-07-12 19:45:03 -04:00
bvandeusen d45ce5e426 Merge pull request 'Ship: Renovate onboarding + dependency modernization batches 1–3' (#221) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
extension / lint (push) Successful in 16s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 1m4s
Build images / build-web (push) Successful in 1m24s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m46s
2026-07-11 23:16:33 -04:00
bvandeusen 77bfc32b02 Merge pull request 'feat(translation): tunable acceptance floor (0.90) + per-post sticky override (#155)' (#210) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 45s
CI / integration (push) Successful in 3m45s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 8s
2026-07-10 22:47:45 -04:00
bvandeusen d13efe1b69 Merge pull request 'refactor(tags): unify suggestion source — one canonical DB-tag dropdown (#154)' (#209) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m44s
2026-07-10 15:52:44 -04:00
bvandeusen ca6d26d236 Merge pull request 'feat(translation): acceptance gate on Interpreter detection confidence (#1376)' (#208) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m43s
2026-07-09 17:58:22 -04:00
bvandeusen 5366047d55 Merge pull request 'feat(translation): 8h sweep + drain button + detection probe (#1376)' (#207) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-08 20:19:33 -04:00
bvandeusen 2923257529 Merge pull request 'fix(ui): tag-chip left-edge clipping + kind-coloured border' (#206) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m45s
2026-07-08 10:37:09 -04:00
bvandeusen 934731e9ef Merge pull request 'feat(translation): per-field language detection for mixed-language posts' (#205) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m48s
2026-07-08 09:25:23 -04:00
bvandeusen 4c67c116b2 Merge pull request 'Bolder auto-tag accept/reject + larger tag chips' (#204) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m50s
2026-07-07 23:21:26 -04:00
bvandeusen f8b667604f Merge pull request 'Graceful shutdown + resilience & Explore polish (m147)' (#203) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 33s
CI / integration (push) Successful in 3m48s
2026-07-07 22:53:34 -04:00
bvandeusen 11572f469a Merge pull request 'Re-translate on Interpreter model change — artist-scoped + global (m146)' (#202) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m41s
2026-07-07 20:22:40 -04:00
bvandeusen ba3fd2a118 Merge pull request 'Post translation via Interpreter (milestone 143)' (#201) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m44s
2026-07-07 13:05:18 -04:00
bvandeusen 9bf095179a Merge pull request 'Auto-tag accept/reject as an in-pill yes/no pair; confirm refocuses the tag input' (#200) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m44s
2026-07-07 01:13:02 -04:00
bvandeusen cefc064e0f Merge pull request 'Surface auto-hide misfires proactively (review strip no longer gated on Show-hidden)' (#199) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m42s
2026-07-07 00:43:45 -04:00
bvandeusen 06757daf80 Merge pull request 'Presentation-chrome auto-hide (milestone 141) + tagging-UX fixes' (#198) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m46s
2026-07-07 00:24:51 -04:00
bvandeusen a723ef436b Merge pull request 'Soft auto-apply (retract + confirm, no self-training) + tagging UX (reject-rest, tag-input race, modal playlist)' (#197) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m46s
2026-07-06 21:03:30 -04:00
bvandeusen d68f39ca50 Merge pull request 'Suggestions perf + UX polish: incremental CCIP + head training, hover label, scroll/toast fixes, dead-preview removal' (#196) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m43s
2026-07-06 16:41:08 -04:00
bvandeusen a66a695977 Merge pull request 'Tag-hover → crop-region grounding overlay (#1206, milestone #133)' (#195) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 17s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m51s
2026-07-06 13:29:31 -04:00
bvandeusen 5289fa3879 Merge pull request 'Crop-proposer detector config → Settings → Tagging (default-on, lease-announced) (#134)' (#194) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-05 20:10:02 -04:00
bvandeusen ebac34e17b Merge pull request 'Artist identity/storage/display decoupling — rename, move-source, add-time name resolution (#130)' (#193) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m40s
2026-07-04 22:43:12 -04:00
bvandeusen 4c3ba20198 Merge pull request 'Recapture image→post linking (#1288) + ugoira timings + system suggestion category' (#192) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m36s
2026-07-04 20:36:58 -04:00
bvandeusen d2acec61ae Merge pull request 'Pixiv native ingester (#129) + SubscribeStar .art fix, disable-clears-failure, system-tag suggestion floor' (#191) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 39s
CI / integration (push) Successful in 3m35s
2026-07-03 16:36:56 -04:00
bvandeusen 15ae5ef4fa Merge pull request 'SubscribeStar .art age fix, modal tag-flow sync, system tags / training hygiene (#128)' (#190) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m34s
2026-07-03 09:16:16 -04:00
bvandeusen e3ceefc820 Merge pull request 'Surgical re-fetch for deep posts (ExternalLink reset) — recovery no longer needs deep scans' (#189) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
Build images / build-agent (push) Successful in 14s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 38s
CI / backend-lint-and-test (push) Successful in 2m10s
CI / integration (push) Successful in 3m40s
2026-07-02 21:14:45 -04:00
bvandeusen e52090a4cf Merge pull request 'Agent sleep mode: empty queue sheds to one downloader, lease poll backs off to 15 min' (#188) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 42s
CI / integration (push) Successful in 3m50s
2026-07-02 20:37:48 -04:00
bvandeusen bfba8045e4 Merge pull request 'Admin IA overhaul, cap-aware autoscaler, optional ml-worker, hardened tag reset' (#187) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 40s
CI / integration (push) Successful in 3m29s
2026-07-02 18:01:56 -04:00
bvandeusen a708357436 Merge pull request 'Settings defaults, GPU error tombstones + failure triage/recovery, agent bandwidth cap, approved retirements' (#186) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m33s
2026-07-02 15:48:48 -04:00
bvandeusen d9354ac1e1 Merge pull request 'Agent: short-video sampling fix + "Retry errored jobs" recovery action' (#185) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-07-01 21:13:48 -04:00
bvandeusen 1d48770793 Merge pull request 'Agent: fix Status freeze — conchint.textContent destroyed #capn (root cause)' (#184) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 20:46:28 -04:00
bvandeusen 489e6aaaee Merge pull request 'Agent: server-side throughput rates + killable-on-stop ffmpeg' (#183) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 20:01:00 -04:00
bvandeusen ed20df905b Merge pull request 'Agent: DRY pass + Status rate-metrics + real start/stop state machine' (#182) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 16:30:32 -04:00
bvandeusen 5ba9871ef0 Merge pull request 'fix(agent): stream videos via ffmpeg-from-URL (no full download) — env-agnostic' (#181) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-07-01 14:20:53 -04:00
bvandeusen 2a820d0848 Merge pull request 'fix(agent): log the real fetch/submit failure reason (diagnose #1225)' (#180) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m28s
2026-07-01 12:37:45 -04:00
bvandeusen 2f9aa3d86c Merge pull request 'perf(web): 4 MiB file streaming + 4 hypercorn workers (fix 40s downloads)' (#179) from dev into main
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 29s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 6s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / integration (push) Successful in 3m26s
2026-07-01 11:55:35 -04:00
bvandeusen 560a5000a2 Merge pull request 'gallery: sort by earliest post date across all posts (new default)' (#178) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 4s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m26s
2026-07-01 10:54:41 -04:00
bvandeusen 7ddad231f8 Merge pull request 'agent: stop downloader pool stampeding a slow curator (congestion collapse)' (#177) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / integration (push) Successful in 3m22s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 35s
2026-07-01 10:45:48 -04:00
bvandeusen a78f7eaace Merge pull request 'explore: more variance in the related rail (stronger MMR diversification)' (#176) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-agent (push) Successful in 8s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-07-01 00:50:59 -04:00
bvandeusen 71337b0ba4 Merge pull request 'agent: temporal video dedup — drop near-duplicate frames before the GPU' (#175) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-07-01 00:38:53 -04:00
bvandeusen 3996205f3b Merge pull request 'agent: dedupe near-duplicate crops before the SigLIP embed' (#174) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 5s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m26s
2026-07-01 00:24:39 -04:00
bvandeusen 9f9db01456 Merge pull request 'agent: download/GPU producer-consumer pipeline + detector fuse fix' (#173) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 23:39:25 -04:00
bvandeusen 216b7fc743 Merge pull request 'Agent: bound video GPU work (early-exit frame loop at max_regions)' (#172) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m26s
2026-06-30 22:57:33 -04:00
bvandeusen fbb76e6f36 Merge pull request 'Agent: huge-image load + figure/region caps + stale-active fix' (#171) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 22:47:48 -04:00
bvandeusen 5ef1478ade Merge pull request 'Modernise agent base → Ubuntu 24.04 / Python 3.12 / CUDA 12.9' (#170) from dev into main
Build images / build-ml (push) Failing after 3s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 8m44s
2026-06-30 22:30:18 -04:00
bvandeusen 88ff4147e1 Merge pull request 'Fix: agent py3.10 startup crash + submit-path retry + pin agent ruff to py310' (#169) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m25s
2026-06-30 22:03:57 -04:00
bvandeusen 1ea02ad44c Merge pull request 'Release: prompt agent stop + lazy curator polling + build marker + agent in CI' (#168) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m23s
2026-06-30 21:42:55 -04:00
bvandeusen 937cfb65b4 Merge pull request 'Release: agent per-stage timing breakdown' (#167) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 5s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m23s
2026-06-30 21:28:57 -04:00
bvandeusen baac851220 Merge pull request 'perf: GPU-job leasing stays O(batch) — partial indexes + two-phase lease' (#166) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 9s
Build images / build-ml (push) Successful in 9s
CI / integration (push) Successful in 3m27s
CI / lint (push) Successful in 4s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-30 21:18:23 -04:00
bvandeusen a28c33281a Merge pull request 'Release: unfreeze agent status view + smoothed throughput autoscaler + log pane' (#165) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 9s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:20:34 -04:00
bvandeusen 40be0a9323 Merge pull request 'Release: agent page no-store cache header' (#164) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Failing after 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m23s
2026-06-30 20:00:36 -04:00
bvandeusen 2c6bf26bfc Merge pull request 'Release: graceful Start/Stop (starting/stopping states) + cached GPU reads' (#163) from dev into main
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 3m24s
2026-06-30 19:38:50 -04:00
bvandeusen 3c1e76bd44 Merge pull request 'Release: stable util-band autoscaler + live GPU meters' (#162) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 5s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m44s
2026-06-30 19:16:29 -04:00
bvandeusen 831c5b1c10 Merge pull request 'Release: agent page — centered column + logs fill to bottom' (#161) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m25s
2026-06-30 19:10:50 -04:00
bvandeusen 76b6af4903 Merge pull request 'Release: agent perf (batched crop embeds) + UI (full-width, copy logs, quiet noise) + truncated-image fix' (#160) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m32s
2026-06-30 18:47:55 -04:00
bvandeusen 92494ec4ed Merge pull request 'Release: agent control-page logs console + styling pass' (#159) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
CI / integration (push) Successful in 3m26s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-30 18:37:35 -04:00
bvandeusen bdfc17477c Merge pull request 'Release: GPU agent autoscaler (throughput hill-climb, Auto default-on)' (#158) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / integration (push) Successful in 3m24s
2026-06-30 18:23:15 -04:00
bvandeusen 6cd3153bf4 Merge pull request 'Release: SigLIP 2 default (new installs) + embedder model dropdown' (#157) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m26s
2026-06-30 18:01:01 -04:00
bvandeusen 5f2853168a Merge pull request 'Release: GPU re-process trigger (apply new crop detectors to the existing library)' (#156) from dev into main
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m22s
Build images / build-agent (push) Successful in 3m35s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
2026-06-30 16:13:36 -04:00
bvandeusen 9a979ee808 Merge pull request 'Release: Explore diversification, model-swap, Camie/centroid retirement, tag-API, crop proposers' (#155) from dev into main
Build images / build-agent (push) Successful in 10m56s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m26s
2026-06-30 16:06:13 -04:00
bvandeusen 3138f912fd Merge pull request 'Release: SigLIP concept crops + max-over-bag + agent redeploy-survival' (#154) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m35s
Build images / build-agent (push) Successful in 7m50s
2026-06-30 08:46:20 -04:00
bvandeusen 9df874e396 Merge pull request 'CCIP automation + reference quality (auto-enqueue, auto-apply, contamination fix, cache)' (#152) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 28s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m20s
Build images / build-ml (push) Successful in 2m40s
Build images / build-agent (push) Successful in 3m17s
CI / integration (push) Successful in 3m27s
2026-06-29 22:29:30 -04:00
bvandeusen 6915a7590a Merge pull request 'fix(tags): refocus tag input after reject/un-reject too' (#151) from dev into main
CI / integration (push) Successful in 3m26s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-29 21:07:21 -04:00
bvandeusen 5e8c28236a Merge pull request 'CCIP match threshold (tunable, 0.85) + Explore → variance' (#150) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m29s
2026-06-29 20:48:14 -04:00
bvandeusen 7e3c0f0b74 Merge pull request 'agent: raise worker cap to 32 + size HTTP pool' (#149) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 1s
CI / backend-lint-and-test (push) Successful in 26s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m47s
Build images / build-agent (push) Successful in 3m11s
CI / integration (push) Successful in 3m31s
2026-06-29 19:42:11 -04:00
bvandeusen 5d0c7ba706 Merge pull request 'fix(agent): flatten transparency onto white before RGB' (#148) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
Build images / build-agent (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m27s
2026-06-29 19:18:48 -04:00
bvandeusen 18300e1f8a Merge pull request 'Agent: GPU load + live worker tuning + fast orphan recovery' (#147) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-agent (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m27s
2026-06-29 19:11:22 -04:00
bvandeusen d52ac0a0e2 Merge pull request 'fix(agent): cuDNN base image so onnxruntime-gpu loads' (#146) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 16s
CI / backend-lint-and-test (push) Successful in 26s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m50s
Build images / build-agent (push) Successful in 3m29s
CI / integration (push) Successful in 3m28s
2026-06-29 18:48:24 -04:00
bvandeusen 401fe8213e Merge pull request 'ci: publish the GPU agent image' (#145) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-agent (push) Successful in 3m15s
CI / integration (push) Successful in 3m27s
2026-06-29 14:26:51 -04:00
bvandeusen e8774d7953 Merge pull request 'CCIP characters + crop/region pipeline + desktop GPU agent (#114)' (#144) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 26s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 2m8s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m26s
2026-06-29 14:18:57 -04:00
bvandeusen bc0f00c51b Merge pull request 'Earned auto-apply (fire + observability + UI), retrain cadences, Explore arrow-nav' (#143) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m30s
CI / integration (push) Successful in 3m24s
2026-06-29 07:30:41 -04:00
bvandeusen 1bef68aa29 Merge pull request 'Tagging-v2: heads are the suggestion source (learn-from-tags) + rail accept/reject' (#142) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m44s
CI / integration (push) Successful in 3m25s
2026-06-28 11:55:49 -04:00
bvandeusen 1a4bc2f981 Merge pull request 'tag-eval: "keep" records a confirmation so doubts stop resurfacing' (#141) from dev into main
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 2m30s
2026-06-28 01:36:26 -04:00
bvandeusen 862ace69d6 Merge pull request 'fix(tag-eval): stop re-suggesting already-rejected items' (#140) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m19s
2026-06-28 01:10:26 -04:00
bvandeusen abf88b1a15 Merge pull request 'tag-eval: auto-apply operating point + server-side top-N concept discovery' (#139) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 20s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m19s
2026-06-28 00:54:14 -04:00
bvandeusen c05dcafbea Merge pull request 'fix(tag-eval): example thumbnail opens the view modal instead of Explore' (#138) from dev into main
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-web (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 27s
2026-06-28 00:11:20 -04:00
bvandeusen 55e8632dab Merge pull request 'Tag-eval review actions: bigger clickable thumbnails + inline confirm/reject' (#137) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m36s
CI / integration (push) Successful in 3m18s
2026-06-27 23:49:12 -04:00
bvandeusen 825e6b90bf Merge pull request 'Tag-eval (heads vs centroid) + focus/provenance/layout/sticky-header fixes + tag-gaps cleanup' (#136) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m31s
CI / integration (push) Successful in 3m21s
2026-06-27 23:03:04 -04:00
bvandeusen fb012c557c Merge pull request 'fix(explore): bound 3-pane grid row so a tall rail can't scroll the page' (#135) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m37s
CI / integration (push) Successful in 3m20s
2026-06-26 23:37:43 -04:00
bvandeusen 66593ab895 Merge pull request 'Explore: focus-everywhere + provenance in rail; tag-gaps cleanup' (#134) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m17s
2026-06-26 21:40:56 -04:00
bvandeusen b266a54ad3 Merge pull request 'feat(explore): auto-focus tag input on every image change' (#133) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m25s
2026-06-26 11:28:32 -04:00
bvandeusen ad803b646f Merge pull request 'fix(modal): place meta + save block under Provenance, above Tags' (#132) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m20s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 11s
2026-06-26 10:14:15 -04:00
bvandeusen 1f5da3d283 Merge pull request 'Fandom count fix + Explore 3-pane workspace + modal rail layout' (#131) from dev into main
Build images / sign-extension (push) Successful in 4s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m21s
2026-06-26 08:24:26 -04:00
bvandeusen 93034f580d Merge pull request 'fix(tags): fandom views aggregate images via their characters' (#130) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m34s
CI / integration (push) Successful in 3m19s
2026-06-26 00:28:21 -04:00
bvandeusen 9b9b12f410 Merge pull request 'Modal suggestions scroll-cap + Celery broker resilience' (#129) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Failing after 2m34s
CI / integration (push) Successful in 3m30s
2026-06-24 08:34:17 -04:00
bvandeusen 376d310693 Merge pull request 'Tagging & viewing roadmap: tag query surface + hygiene projections + Explore view (Clusters A/B/C)' (#128) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m6s
Build images / build-ml (push) Successful in 2m39s
CI / integration (push) Successful in 3m24s
2026-06-23 08:57:34 -04:00
bvandeusen bc69495a16 Merge pull request 'Provenance archive linkage, post reconciliation close-out, and the cleanup/admin DRY pass' (#127) from dev into main
Build images / build-web (push) Successful in 2m2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m22s
2026-06-22 20:22:56 -04:00
bvandeusen 478f898e72 Merge pull request 'feat(settings): Maintenance tab compact tiles + centered Settings (pass 2)' (#126) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
Build images / build-web (push) Successful in 2m18s
Build images / build-ml (push) Successful in 3m3s
CI / integration (push) Successful in 3m26s
2026-06-18 00:11:54 -04:00
bvandeusen 38a5e7f332 Merge pull request 'feat(settings): tidy Cleanup tab into sectioned compact tiles (pass 1)' (#125) from dev into main
CI / backend-lint-and-test (push) Failing after 2s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m19s
2026-06-17 23:49:21 -04:00
bvandeusen 57fe15c267 Merge pull request 'feat(maintenance): reconcile duplicate posts (gallery-dl→native unify)' (#124) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m22s
2026-06-17 22:01:12 -04:00
bvandeusen eb3231ef10 Merge pull request 'fix(subscribestar): port gallery-dl date extraction (wrapped dates) + parse canary' (#123) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m19s
2026-06-17 21:15:43 -04:00
bvandeusen e9af459c0d Merge pull request 'feat(subscribestar): port gallery-dl doc + audio attachment extraction' (#122) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 9s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m15s
2026-06-17 16:20:12 -04:00
bvandeusen 6f02806aec Merge pull request 'fix(subscribestar): port gallery-dl content + preview-skip extraction (body bug)' (#121) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m25s
2026-06-17 16:10:56 -04:00
bvandeusen a1d19bd96a Merge pull request 'fix(subscribestar): mirror gallery-dl's full request profile (verify_subscriber gate)' (#120) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m24s
2026-06-17 15:32:28 -04:00
bvandeusen 26827ff38f Merge pull request 'fix(subscribestar): match gallery-dl's generic post delimiter (live-feed drift)' (#119) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 38s
CI / integration (push) Successful in 3m15s
2026-06-17 15:07:59 -04:00
bvandeusen 26dcfaf6c2 Merge pull request 'fix(subscribestar): initial feed GET is a navigation, not XHR (first-run drift)' (#118) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m14s
2026-06-17 14:30:02 -04:00
bvandeusen 9b1b0369cc Merge pull request 'SubscribeStar → native core ingester + native-ingest DRY pass (milestone #71)' (#117) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-17 12:48:29 -04:00
bvandeusen 18123fb9cb Merge pull request 'fix(external): recovery-sweep threshold + queue recording + split fetch timeouts (#883)' (#116) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m14s
2026-06-16 21:24:31 -04:00
bvandeusen 2e806f202f Merge pull request 'test(artist-dir): fix flaky uq_image_record_sha256 collision' (#115) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 6s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 31s
CI / integration (push) Successful in 3m17s
2026-06-16 20:34:21 -04:00
bvandeusen 18d5c05639 Merge pull request 'fix(ml): per-task async engine for recompute_centroid (#881)' (#114) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Failing after 3m19s
2026-06-16 20:24:37 -04:00
bvandeusen 11ddfc3876 Merge pull request 'fix(maint): resurface dedup/gated-purge results after navigate-away (#877)' (#113) from dev into main
Build images / sign-extension (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
Build images / build-web (push) Successful in 15s
CI / backend-lint-and-test (push) Successful in 29s
CI / integration (push) Successful in 3m20s
2026-06-16 16:48:52 -04:00
bvandeusen 2b8ce86622 Merge pull request 'Gated Patreon posts: skip on ingest + cleanup tool (#874)' (#112) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 34s
CI / integration (push) Successful in 3m27s
2026-06-16 15:25:34 -04:00
bvandeusen 49bee77cdc Merge pull request 'Video tag quality: cadence sampling + min-frame aggregation + ML thread cap (#747)' (#111) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 30s
CI / integration (push) Successful in 3m15s
2026-06-16 14:08:40 -04:00
bvandeusen c209e3b37e Merge pull request 'Tier-1 video dedup: import-time + retroactive cleanup (#871)' (#110) from dev into main
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 17s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 26s
2026-06-16 08:55:38 -04:00
bvandeusen cffdd93418 Merge pull request 'Nested-archive extraction (#718) + post-first ingest (#67) + post-body canary (#862)' (#109) from dev into main
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m19s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
2026-06-15 21:37:39 -04:00
bvandeusen fd84be40dd Merge pull request 'External-attach orphan fix (#859) + image-less post display' (#108) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 40s
Build images / build-web (push) Successful in 10s
CI / integration (push) Successful in 3m19s
2026-06-15 01:55:36 -04:00
bvandeusen 79f510d7f8 Merge pull request 'Merge dev → main: read post body from content_json_string (the empty-body fix) (#842)' (#107) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m24s
Build images / sign-extension (push) Successful in 3s
2026-06-15 00:19:45 -04:00
bvandeusen 59181069da Merge pull request 'Merge dev → main: per-post stdout diagnostics + post-field write DRY (#842/#753)' (#106) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 41s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m15s
Build images / build-ml (push) Successful in 2m55s
CI / integration (push) Successful in 3m11s
2026-06-14 23:45:39 -04:00
bvandeusen 428ecd8642 Merge pull request 'Merge dev → main: per-post body-capture diagnostics in the event UI (#842)' (#105) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / integration (push) Successful in 3m11s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 37s
2026-06-14 23:14:14 -04:00
bvandeusen ed1e04b831 Merge pull request 'Merge dev → main: recapture body-fetch fix + diagnostics (#842)' (#104) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 35s
CI / integration (push) Successful in 3m15s
2026-06-14 22:31:41 -04:00
bvandeusen f5156bd847 Merge pull request 'Merge dev → main: Recapture mode (#842) — re-grab post bodies/links + localize on-disk inline images' (#103) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-web (push) Successful in 2m30s
CI / integration (push) Successful in 3m29s
Build images / build-ml (push) Successful in 3m27s
2026-06-14 21:21:49 -04:00
bvandeusen dfc3922d24 Merge pull request 'Merge dev → main: #830 rich post capture + external-host downloads (+ #768/#789/#739)' (#102) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 48s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m47s
CI / integration (push) Successful in 3m20s
2026-06-14 19:16:09 -04:00
bvandeusen 3eb08e926b Merge pull request 'fix(aliases): modal raw-key bug + alias visibility/management' (#101) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-12 14:02:00 -04:00
bvandeusen 9e81ced359 Merge pull request 'fix(images): percent-encode original-image URLs ('#' in paths 404'd)' (#100) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 36s
CI / integration (push) Successful in 3m15s
2026-06-12 00:44:56 -04:00
bvandeusen 11e9f5af60 Merge pull request 'fix(browse): tabs and search on one row' (#99) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 11s
CI / integration (push) Successful in 3m13s
2026-06-12 00:28:38 -04:00
bvandeusen 909fa37b15 Merge pull request 'Browse search + series numbering rework + kebab fix' (#98) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 2m50s
CI / integration (push) Successful in 3m16s
2026-06-12 00:14:31 -04:00
bvandeusen dfab8f65ff Merge pull request 'feat(series): flat sequence + cosmetic dividers + pending staging (#789)' (#97) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 29s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m13s
Build images / build-ml (push) Successful in 2m49s
CI / integration (push) Successful in 3m9s
2026-06-11 22:07:57 -04:00
bvandeusen 618f7cdc36 Merge pull request 'feat(ml): drop image_record.tagger_predictions — image_prediction is sole store (#768 step 3)' (#96) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m29s
Build images / build-ml (push) Successful in 3m0s
CI / integration (push) Successful in 3m13s
2026-06-11 19:32:05 -04:00
bvandeusen 028ea33a7c Merge pull request 'fix(migration): make 0045 DDL-only; backfill image_prediction via batched task (#768)' (#95) from dev into main
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m58s
CI / integration (push) Successful in 3m15s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 2m50s
2026-06-11 09:22:22 -04:00
bvandeusen 444c1fb075 Merge pull request 'perf(migration): 0045 streams json_each (no materialize / no temp blowup)' (#94) from dev into main
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m14s
Build images / build-ml (push) Successful in 2m36s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 32s
CI / integration (push) Successful in 3m8s
2026-06-10 22:07:16 -04:00
bvandeusen 26c68b0a75 Merge pull request 'fix(migration): 0045 guards json_each against scalar tagger_predictions' (#93) from dev into main
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m13s
Build images / sign-extension (push) Successful in 2s
Build images / build-web (push) Successful in 6s
Build images / build-ml (push) Successful in 2m40s
2026-06-10 20:33:27 -04:00
bvandeusen e75427b19a Merge pull request '#768 steps 1+2: normalized image_prediction table (read cutover)' (#92) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m4s
Build images / build-ml (push) Successful in 2m42s
CI / integration (push) Successful in 3m8s
2026-06-10 20:15:26 -04:00
bvandeusen 5447fab987 Merge pull request 'Activity search + RecoverySweep fix + tagger_predictions shrink (#762, #764) + backup polish' (#91) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-web (push) Successful in 2m23s
Build images / build-ml (push) Successful in 2m51s
CI / integration (push) Successful in 3m10s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 19s
2026-06-10 14:33:35 -04:00
bvandeusen bad37e07b2 Merge pull request 'Browse hub, series rename, full-prediction dropdown + a DRY pass (7 sweeps)' (#90) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 27s
CI / frontend-build (push) Successful in 44s
Build images / build-ml (push) Successful in 3m2s
CI / integration (push) Successful in 3m15s
Build images / build-web (push) Successful in 2m17s
2026-06-10 00:24:01 -04:00
bvandeusen 2bfc9936a1 Merge pull request 'UI batch: tagging flow, series browse, fandom chips, nav' (#89) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 39s
Build images / sign-extension (push) Successful in 3s
CI / frontend-build (push) Successful in 26s
Build images / build-web (push) Successful in 2m21s
Build images / build-ml (push) Successful in 2m52s
CI / integration (push) Successful in 3m24s
2026-06-09 20:48:19 -04:00
bvandeusen 4c6406ee18 Merge pull request 'fix(posts): link duplicate items to every post + prune bare shells' (#88) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m19s
Build images / build-ml (push) Successful in 2m41s
CI / integration (push) Successful in 3m5s
2026-06-08 19:42:31 -04:00
bvandeusen bb47e80b3e Merge pull request 'fix(cleanup): unused-tags delete must use the same predicate as the preview' (#87) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m13s
2026-06-08 18:17:49 -04:00
bvandeusen dc1083b5e0 Merge pull request 'Unused-tag fandom fix + ML-worker logging/tuning + unified dropdown Enter' (#86) from dev into main
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m16s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 2m21s
2026-06-08 17:27:55 -04:00
bvandeusen e46893fefd Merge pull request 'Migration lock safety + remove the merge's full-table scan (the real 0040-hang fix)' (#85) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m33s
CI / integration (push) Successful in 3m7s
2026-06-08 01:03:06 -04:00
bvandeusen 0666e15211 Merge pull request 'Series manage redesign (FC-6.4) + migration/normalize hardening + UX fixes' (#84) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 1m58s
Build images / build-ml (push) Successful in 2m28s
CI / integration (push) Successful in 3m6s
2026-06-07 21:17:57 -04:00
bvandeusen 747390631d Merge pull request 'FC-6 series authoring + backup/NFS hardening + UX fixes' (#83) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m43s
CI / integration (push) Successful in 3m6s
2026-06-07 19:31:04 -04:00
bvandeusen e0d2a20588 Merge pull request 'Modal focus/keyboard polish, Camie-in-autocomplete, re-extract self-resume' (#82) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 25s
CI / backend-lint-and-test (push) Successful in 28s
Build images / build-web (push) Successful in 2m2s
Build images / build-ml (push) Successful in 2m32s
CI / integration (push) Successful in 3m9s
2026-06-07 12:17:43 -04:00
bvandeusen 1d84f67418 Merge pull request 'Modal: large centered spinner + kebab z-index fix' (#81) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 29s
Build images / build-web (push) Successful in 2m3s
Build images / build-ml (push) Successful in 2m35s
CI / integration (push) Successful in 3m7s
2026-06-07 10:57:00 -04:00
bvandeusen 91265df3d6 Merge pull request 'Maintenance-queue health + modal/tagging keyboard pass' (#80) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 20s
CI / backend-lint-and-test (push) Successful in 25s
Build images / build-web (push) Successful in 2m5s
Build images / build-ml (push) Successful in 2m26s
CI / integration (push) Successful in 3m1s
2026-06-07 10:31:01 -04:00
bvandeusen 11acdb0322 Merge pull request 'Patreon: a missing media file_name is a fallback, not API drift' (#79) from dev into main
CI / lint (push) Successful in 2s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 23:14:25 -04:00
bvandeusen 2eb9fd5dd0 Merge pull request 'Patreon: enforce the backfill time-box mid-post (stop soft-limit overruns)' (#78) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m3s
2026-06-06 23:00:29 -04:00
bvandeusen 01e5ce1410 Merge pull request 'Patreon: resolve creator campaign from a single-post URL' (#77) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m4s
2026-06-06 21:38:33 -04:00
bvandeusen 3bb94674cf Merge pull request 'Patreon download concurrency cap + immediate backfill kickoff on create' (#76) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 21:27:22 -04:00
bvandeusen a75c602175 Merge pull request 'Subscriptions UX overhaul + Patreon /cw/ vanity fix' (#75) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 28s
CI / integration (push) Successful in 3m2s
2026-06-06 21:12:02 -04:00
bvandeusen ef8f4f7193 Merge pull request 'Tag-casing acronym fix, Patreon resolver hardening, archive diagnostics, post-card strip' (#74) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 26s
CI / integration (push) Successful in 3m1s
2026-06-06 19:59:32 -04:00
bvandeusen ec3d27b219 Merge pull request 'Tag-maintenance sweep + bug-fix batch: #699 #700 #701 #709 #711 #712 #713 #714' (#73) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 27s
CI / integration (push) Successful in 3m2s
2026-06-06 16:49:00 -04:00
bvandeusen 03bd3b2eda Merge pull request 'Native Patreon ingester + download-engine ownership (plans #697, #703–#708)' (#72) from dev into main
Build images / build-ml (push) Successful in 7s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 25s
CI / frontend-build (push) Successful in 32s
CI / integration (push) Successful in 3m1s
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
2026-06-06 12:57:31 -04:00
bvandeusen 7395e77d75 Merge pull request 'Smarter backfill: time-boxed chunks, run-until-done (plan #693)' (#71) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m58s
2026-06-05 16:33:06 -04:00
bvandeusen 575d817919 Merge pull request '#70 dev→main: cursor-paged backfill + mobile modal fixes' from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / integration (push) Successful in 2m59s
2026-06-05 11:10:02 -04:00
bvandeusen 2a8f7cd8b6 Merge pull request '#69 dev→main: release v26.06.04.0' from dev into main
CI / lint (push) Successful in 2s
CI / backend-lint-and-test (push) Successful in 12s
CI / frontend-build (push) Successful in 19s
CI / integration (push) Successful in 3m2s
Build images / sign-extension (push) Has been skipped
Build images / build-ml (push) Successful in 6s
Build images / build-web (push) Successful in 6s
2026-06-04 23:16:12 -04:00
bvandeusen 83f8af8090 Merge pull request 'dev→main: surface near-duplicate (pHash) control + reorder import tab' (#68) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 2m56s
2026-06-04 21:39:44 -04:00
bvandeusen 9a2617c1a2 Merge pull request 'dev→main: post-card redesign (images→modal, in-place text expand)' (#67) from dev into main
CI / lint (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 11s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 24s
CI / integration (push) Successful in 2m57s
2026-06-04 17:32:50 -04:00
bvandeusen 81688815a0 Merge pull request 'dev→main: similar-search render fix + reset-content-tagging + scan persistence' (#66) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m58s
2026-06-04 16:59:52 -04:00
bvandeusen 773128c3bf Merge pull request 'dev→main: purpose-built mobile layout for subscriptions hub' (#65) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 38s
CI / integration (push) Successful in 2m56s
2026-06-04 13:43:56 -04:00
bvandeusen ce7b154ae9 Merge pull request 'dev→main: subscriptions table mobile card layout' (#64) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 22s
CI / integration (push) Successful in 2m57s
2026-06-04 12:56:58 -04:00
bvandeusen 9430a9d9c3 Merge pull request 'dev→main: gallery similarity search (Phase 3) + UI/mobile polish' (#63) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 21s
CI / integration (push) Successful in 2m56s
2026-06-04 11:21:26 -04:00
bvandeusen 23aee56ce3 Merge pull request 'dev→main: showcase cascade + filter styling + DB maintenance + gallery filter Phase 2 + showcase decode-gate + CI perf' (#62) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 11s
CI / frontend-build (push) Successful in 20s
CI / integration (push) Successful in 3m0s
2026-06-04 08:27:47 -04:00
bvandeusen 711abea567 Merge pull request 'Gallery speed + fandom editing + filters + pinned filter bar' (#61) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 18s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 11s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m29s
2026-06-04 00:07:19 -04:00
bvandeusen 844bb86802 Merge pull request 'fix(download): release DB connections across the gallery-dl subprocess' (#60) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 25s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m41s
CI / intcore (push) Successful in 8m24s
2026-06-03 22:11:36 -04:00
bvandeusen a8f6a464aa Merge pull request 'fix(download): salvage soft-time-limit kills + fix timeout ladder' (#59) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 21s
CI / frontend-build (push) Successful in 24s
Build images / build-web (push) Successful in 2m37s
Build images / build-ml (push) Successful in 3m21s
CI / intimp (push) Successful in 3m40s
CI / intapi (push) Successful in 7m47s
CI / intcore (push) Successful in 8m15s
2026-06-03 19:35:45 -04:00
bvandeusen ab9922ad2e Merge pull request 'feat(artist): "new since last visit" badge + banner' (#58) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
Build images / build-ml (push) Successful in 2m53s
CI / frontend-build (push) Successful in 21s
Build images / build-web (push) Successful in 2m9s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m9s
2026-06-03 16:20:54 -04:00
bvandeusen 0533807669 Merge pull request 'feat(ext): verify cookies in-browser before uploading (1.0.7)' (#57) from dev into main
CI / backend-lint-and-test (push) Successful in 32s
Build images / build-ml (push) Successful in 3m28s
Build images / build-web (push) Successful in 3m42s
CI / intapi (push) Successful in 9m32s
CI / frontend-build (push) Successful in 20s
extension / lint (push) Successful in 14s
Build images / sign-extension (push) Successful in 3m17s
CI / intimp (push) Successful in 4m26s
CI / lint (push) Successful in 3s
CI / intcore (push) Successful in 10m18s
2026-06-03 14:17:42 -04:00
bvandeusen 279dff3fb6 Merge pull request 'feat(ml): normalize Camie suggestion names to human-readable' (#56) from dev into main
Build images / build-ml (push) Successful in 2m55s
CI / intimp (push) Successful in 3m36s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m24s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
Build images / build-web (push) Successful in 2m17s
2026-06-03 13:18:44 -04:00
bvandeusen 37e66cddc4 Merge pull request 'chore(modal): drop ?image=N soft-compat — pure overlay' (#55) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / frontend-build (push) Successful in 27s
CI / backend-lint-and-test (push) Successful in 38s
Build images / build-ml (push) Successful in 2m46s
Build images / build-web (push) Successful in 2m36s
CI / intimp (push) Successful in 3m42s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m12s
2026-06-02 19:35:04 -04:00
bvandeusen 9cf6b2d363 Merge pull request 'audit-g5 final + ML threshold default + kebab menu fix' (#54) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 4s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 27s
Build images / build-web (push) Successful in 3m0s
Build images / build-ml (push) Successful in 3m45s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 8m8s
CI / intcore (push) Successful in 8m34s
2026-06-02 19:09:49 -04:00
bvandeusen 6ef0fed41f Merge pull request 'audit-g5: architectural debt — 4 bundles (A/B/C/D)' (#53) from dev into main
CI / lint (push) Successful in 2s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 18s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 26s
CI / intimp (push) Successful in 3m51s
CI / intapi (push) Successful in 7m38s
CI / intcore (push) Successful in 8m52s
2026-06-02 18:07:25 -04:00
bvandeusen 89b48f8f35 Merge pull request 'audit-g4: status-enum miss batch' (#52) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 13s
Build images / build-web (push) Successful in 10s
CI / frontend-build (push) Successful in 35s
CI / intimp (push) Successful in 3m41s
CI / intapi (push) Successful in 7m25s
CI / intcore (push) Successful in 8m10s
2026-06-02 16:15:00 -04:00
bvandeusen d60e0b9494 Merge pull request 'audit-g3: lifecycle batch — recovery sweeps, retention, timeouts' (#51) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 7s
CI / frontend-build (push) Successful in 36s
CI / intimp (push) Successful in 3m39s
CI / intapi (push) Successful in 7m39s
CI / intcore (push) Successful in 8m10s
2026-06-02 14:49:28 -04:00
bvandeusen 9c27a2d3c7 Merge pull request 'audit-g2: async race / state-leak fixes across eight stores' (#50) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 23s
CI / frontend-build (push) Successful in 37s
Build images / build-web (push) Successful in 13s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 8m15s
CI / intcore (push) Successful in 8m50s
2026-06-02 14:17:12 -04:00
bvandeusen 93e37681b7 Merge pull request 'audit-g1: six one-liner drift fixes from 2026-06-02 audit' (#49) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 11s
CI / backend-lint-and-test (push) Successful in 11s
CI / frontend-build (push) Successful in 19s
Build images / build-web (push) Successful in 10s
CI / intapi (push) Successful in 7m28s
CI / intcore (push) Successful in 8m3s
CI / intimp (push) Successful in 3m42s
2026-06-02 13:29:17 -04:00
bvandeusen 64ca858574 Merge pull request 'UX fixes: suggestion-accept chip refresh, showcase endless feed, non-media downloads' (#48) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Successful in 9s
CI / backend-lint-and-test (push) Successful in 12s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m52s
CI / intapi (push) Successful in 7m45s
CI / intcore (push) Successful in 8m20s
2026-06-02 08:26:58 -04:00
bvandeusen 9d0c0b7da8 Merge pull request 'fix(thumbnails): surface backfill counts + tighten validity check' (#47) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 18s
CI / backend-lint-and-test (push) Successful in 18s
CI / intimp (push) Successful in 3m45s
CI / intapi (push) Successful in 7m32s
CI / intcore (push) Successful in 8m4s
2026-06-01 22:34:38 -04:00
bvandeusen 8e4d252ae4 Merge pull request 'fix(downloads): enqueue thumbnail + ML per attached image' (#46) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / intimp (push) Successful in 3m40s
CI / lint (push) Successful in 3s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m46s
Build images / build-ml (push) Successful in 8s
Build images / build-web (push) Successful in 8s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 26s
2026-06-01 21:52:42 -04:00
bvandeusen fdd3e01f56 Merge pull request 'ux(failing-sources): visible row separators + clearer hover' (#45) from dev into main
CI / lint (push) Successful in 3s
Build images / sign-extension (push) Successful in 3s
Build images / build-ml (push) Successful in 10s
Build images / build-web (push) Successful in 10s
CI / backend-lint-and-test (push) Successful in 20s
CI / frontend-build (push) Successful in 33s
CI / intimp (push) Successful in 3m44s
CI / intapi (push) Successful in 7m48s
CI / intcore (push) Successful in 8m16s
2026-06-01 21:09:28 -04:00
bvandeusen c82fb308b6 Merge pull request 'Post.source_id refactor + tick/backfill modes + PARTIAL classifier + Mux fix + UX' (#44) from dev into main
Build images / build-ml (push) Failing after 2s
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
CI / intimp (push) Successful in 3m47s
CI / intapi (push) Successful in 7m52s
CI / intcore (push) Successful in 8m16s
Build images / build-web (push) Successful in 12s
CI / backend-lint-and-test (push) Successful in 19s
CI / frontend-build (push) Successful in 31s
2026-06-01 20:44:23 -04:00
bvandeusen 8cf8d2ca4d Merge pull request 'Modal Esc/overflow polish, artist-scoped post scroll, failing-sources Logs button' (#43) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 2m45s
Build images / build-ml (push) Successful in 3m31s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m47s
2026-06-01 12:10:11 -04:00
bvandeusen b1d58bc3b8 Merge pull request 'fix(ci): POSIX-safe SHORT_SHA in build.yml' (#42) from dev into main
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 20s
Build images / sign-extension (push) Successful in 3s
Build images / build-web (push) Successful in 2m17s
Build images / build-ml (push) Successful in 3m24s
CI / intimp (push) Successful in 3m22s
CI / intapi (push) Successful in 7m51s
CI / intcore (push) Successful in 8m5s
2026-06-01 08:03:17 -04:00
bvandeusen 65386f02a0 Merge pull request 'View modal batch: autofocus, suggestions UX, post-title click, retire copyright/artist' (#41) from dev into main
Build images / sign-extension (push) Successful in 2s
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 4s
Build images / build-web (push) Failing after 3s
CI / backend-lint-and-test (push) Successful in 15s
CI / frontend-build (push) Successful in 17s
CI / intimp (push) Successful in 3m46s
CI / intapi (push) Successful in 7m17s
CI / intcore (push) Successful in 8m2s
2026-06-01 07:01:42 -04:00
bvandeusen 667b05f14e Merge pull request 'Extension probe-and-add (v1.0.6) + per-commit image tags' (#40) from dev into main
CI / lint (push) Successful in 2s
Build images / build-ml (push) Failing after 2s
CI / frontend-build (push) Successful in 19s
CI / backend-lint-and-test (push) Successful in 22s
extension / lint (push) Successful in 8s
Build images / sign-extension (push) Successful in 1m51s
Build images / build-web (push) Failing after 4s
CI / intimp (push) Successful in 3m38s
CI / intapi (push) Successful in 7m20s
CI / intcore (push) Successful in 7m45s
2026-06-01 01:44:03 -04:00
bvandeusen 856e9104b4 Merge pull request 'Sidecar synthetic anchor cleanup + tier-gated classifier fix' (#39) from dev into main
CI / lint (push) Successful in 5s
CI / frontend-build (push) Successful in 22s
CI / backend-lint-and-test (push) Successful in 28s
CI / intimp (push) Successful in 3m54s
Build images / sign-extension (push) Has been skipped
Build images / build-web (push) Successful in 1m1s
Build images / build-ml (push) Successful in 1m21s
CI / intapi (push) Successful in 7m31s
CI / intcore (push) Successful in 8m0s
2026-06-01 00:16:58 -04:00
bvandeusen 0397642b21 Merge pull request 'Showcase cadence tuning + cooldown-aware bulk retry' (#38) from dev into main 2026-05-30 23:50:36 -04:00
bvandeusen 237575447d Merge pull request 'Thumbnail URL fix + archive daemon fix + batched initial loads' (#37) from dev into main 2026-05-30 22:01:43 -04:00
bvandeusen ed358757dc Merge pull request 'Most-overdue-first scheduling + rich timeout diagnostics' (#36) from dev into main 2026-05-30 14:30:41 -04:00
bvandeusen d181f4afb8 Merge pull request 'Downloads burst-prevention + maintenance-menu fix + gdl timeout' (#35) from dev into main 2026-05-30 11:43:18 -04:00
bvandeusen 2886fa4997 Merge pull request 'Tooltip !important fix — 104cac5 follow-up after Vite CSS reorder' (#34) from dev into main 2026-05-30 00:02:24 -04:00
bvandeusen f256f587ee Merge pull request 'UI batch + I1–I6 service passes + download-event recovery sweep' (#33) from dev into main 2026-05-29 22:46:16 -04:00
bvandeusen 384d8d5e50 Merge pull request 'Dashboard insights + project-wide DRY pass' (#32) from dev into main 2026-05-28 15:38:26 -04:00
bvandeusen 319e8c1d18 Merge pull request 'v26.05.28.0: downloads dashboard + task-resilience overhaul (timeouts, archive split, 3-layer poison-pill defense)' (#31) from dev into main 2026-05-28 00:45:00 -04:00
bvandeusen 9075d8eadd Merge pull request 'v26.05.27.2: subscribestar + HF cookie quirks, platforms package refactor, showcase IR-parity, secure-context audit' (#30) from dev into main 2026-05-27 21:34:02 -04:00
bvandeusen 88e53e5b86 Merge pull request 'v26.05.27.1: subscriptions hub + post-card merge + sidecar audit' (#29) from dev into main 2026-05-27 17:12:48 -04:00
bvandeusen 37e8b796a1 Merge pull request 'v26.05.27.0: PostCard redesign + IR-style tag suffix + drop meta/rating + extension v1.0.4 CSP fix' (#28) from dev into main 2026-05-27 11:31:18 -04:00
bvandeusen 4e82208926 Merge pull request 'v26.05.26.5 — extension CORS unblock + UI gap closes + CI workflow cleanup' (#27) from dev into main 2026-05-26 20:15:07 -04:00
bvandeusen 52fff00353 Merge pull request 'v26.05.26.4 — hotfix: migration 0022 pre-DELETE colliding ImageProvenance before UPDATE' (#26) from dev into main 2026-05-26 18:06:20 -04:00
bvandeusen c14338cbce Merge pull request 'v26.05.26.3 — hotfix: migration 0022 pre-merge across ENTIRE (canonical+others) group' (#25) from dev into main 2026-05-26 17:52:59 -04:00
bvandeusen 8c36dd28b0 Merge pull request 'v26.05.26.2 — hotfix: alembic 0022 Post-collision pre-merge + ci.yml cache continue-on-error' (#24) from dev into main 2026-05-26 16:50:43 -04:00
bvandeusen 88cfb3dd02 Merge pull request 'v26.05.26.1 — thumb backfill, modal redesign, recovery sweep race-safety, artist view redesign, extension fixes' (#23) from dev into main 2026-05-26 16:32:00 -04:00
bvandeusen 5d4f223b71 Merge pull request 'Release v26.05.25.7 — FC-Cleanup tab + UniqueViolation fix + error modal + extension install fix' (#22) from dev into main 2026-05-26 08:26:46 -04:00
bvandeusen 05090c6e85 Merge pull request 'Release v26.05.25.7 — animated-WebP worker fix + FC-Cleanup backend' (#21) from dev into main 2026-05-26 01:48:13 -04:00
bvandeusen 3a577d5ade Merge pull request 'fix(ext-ci): use browser_download_url + curl -f + ZIP magic check (XPI silently corrupt)' (#20) from dev into main 2026-05-26 00:43:02 -04:00
bvandeusen f4fe02e346 Merge pull request 'fix(ext-ci): drop actions/upload-artifact (Forgejo doesn't support v4+ GHES)' (#19) from dev into main 2026-05-25 23:33:40 -04:00
bvandeusen e766197d99 Merge pull request 'fix(ext-ci): jq→python + bump ext to 1.0.3 + rollback-on-upload-failure' (#18) from dev into main 2026-05-25 23:14:51 -04:00
bvandeusen 3872e1dda9 Merge pull request 'fix(ext-ci): web-ext v8 .cjs config workaround' (#17) from dev into main 2026-05-25 22:49:14 -04:00
bvandeusen 9814f3dbaf Merge pull request 'Release v26.05.25.5 — Extension publish refactor, deep-scan IR-parity, archive-import perf, artist Settings tab' (#16) from dev into main 2026-05-25 22:44:59 -04:00
bvandeusen b214460fdb Merge pull request 'Release v26.05.25.4 — importer ext sanitize fix, CI shard split, BrowserExtensionCard on Overview' (#15) from dev into main 2026-05-25 21:11:50 -04:00
bvandeusen ac55d0e8d8 Merge pull request 'fix(ext-ci): match AMO-renamed signed XPI' (#14) from dev into main 2026-05-25 18:22:50 -04:00
bvandeusen 89a89e0ded Merge pull request 'Release v26.05.25.3 — ML embedder SigLIP fix, import-UX, extension publish' (#13) from dev into main 2026-05-25 17:56:50 -04:00
bvandeusen 4e9aac2c05 Merge pull request 'v26.05.25.2: supersede + sidecar enrichment, scan toast feedback, CI uv + pip cache + durations' (#12) from dev into main 2026-05-25 14:30:25 -04:00
bvandeusen 2879ac6f2b Merge pull request 'v26.05.25.1: maintenance sweep + Camie v2 + corrupt-file handling + post-date gallery + clear-stuck escape hatch' (#11) from dev into main 2026-05-25 12:57:46 -04:00
bvandeusen b8dce6c483 Merge pull request 'FC-3h + FC-3k: backup first-class + admin destructive actions' (#10) from dev into main 2026-05-25 01:41:53 -04:00
bvandeusen d1c0b82a22 Merge pull request 'v26.05.24.3: FC-3i System Activity dashboard + migration backup-gate retired + modal Escape' (#9) from dev into main 2026-05-24 21:47:53 -04:00
bvandeusen 5526b8dc78 Merge pull request 'v26.05.24.2: IR Post/Provenance restore + modal artist fallback' (#8) from dev into main 2026-05-24 14:30:06 -04:00
bvandeusen 16eb7075c4 Merge pull request 'v26.05.24.1: FC-3g Firefox extension + worker resilience + UI/migration fixes' (#7) from dev into main 2026-05-24 12:52:31 -04:00
bvandeusen 885dcf64f3 Merge pull request 'v26.05.24.0: TopNav re-fix (flex 1 1 0 side cells)' (#6) from dev into main 2026-05-23 22:49:29 -04:00
bvandeusen f2f6b6d25e Merge pull request 'v26.05.23.3: dogfood UX polish + accurate active-batch stats' (#5) from dev into main 2026-05-23 22:05:59 -04:00
bvandeusen 0822240fde Merge pull request 'v26.05.23.2: serve /images + artist cleanup migrator' (#4) from dev into main 2026-05-23 12:19:16 -04:00
bvandeusen 27f7f3fd01 Merge pull request 'v26.05.23.1: migration durability + dogfood UX' (#3) from dev into main 2026-05-23 11:21:33 -04:00
bvandeusen c5bf564f53 Merge dev: v26.05.23.0 migration follow-ups (#2)
pg_dump + zstd in runtime image, lift Quart body cap to 1 GiB. See PR #2.
2026-05-22 22:37:06 -04:00
bvandeusen 602c7d275d Merge dev: FC-1 → FC-5 v1 build (#1)
First merge of `dev` into `main` for FabledCurator. Brings FC-1 (Foundation) through FC-5 (Migration tooling) onto `main`. See PR #1 body for the full stage rollup.
2026-05-22 14:15:45 -04:00
63 changed files with 5327 additions and 437 deletions
+370
View File
@@ -0,0 +1,370 @@
# TEMPORARY — milestone 328 steps 1-2. Delete once the baseline is stamped.
#
# Squashing 87 alembic revisions into one baseline has exactly one dangerous
# failure: the generated baseline does not reproduce the schema the chain
# produced, `alembic stamp` writes a version string anyway (it validates
# NOTHING), and the divergence surfaces on the next real migration against the
# operator's live data.
#
# So this workflow does the comparison in CI, where a pgvector Postgres already
# gets built from the chain on every integration run, and nothing is at risk.
# It answers one question: does `upgrade head` on the collapsed chain produce a
# byte-identical schema to `upgrade head` on the 87-revision chain?
#
# The chain is read from git rather than from the working tree, so this keeps
# working AFTER the old revisions are deleted — `chain_ref` names a commit that
# still has them. That is what makes this the proof for step 1 and the
# pre-flight for step 2, rather than a one-shot script.
#
# While the chain is still present it also autogenerates a candidate baseline
# from the models and prints it. That is a starting point, NOT the answer:
# autogenerate reads SQLAlchemy metadata, and three things here do not live
# there —
# * CREATE EXTENSION vector (0001)
# * CREATE EXTENSION tsm_system_rows (0004)
# * the HNSW index on image_record.siglip_embedding, which is raw SQL
# because alembic's create_index cannot express `USING hnsw (...)` (0036)
# plus any CHECK constraint or server_default that a migration added without
# the model declaring it. Those must be hand-added, and the diff below is what
# proves none were missed.
name: Alembic baseline
on:
workflow_dispatch:
inputs:
chain_ref:
description: 'Commit/tag carrying the full chain; blank = this ref (use a pinned commit only AFTER the collapse)'
type: string
default: ''
mode:
description: 'chain = compare against this tree''s migrations; models = compare against a schema built from the MODELS'
type: string
default: 'chain'
jobs:
compare:
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
steps:
- uses: actions/checkout@v4
with:
# Full history is the point: `chain_ref` is read out of git, so a
# shallow clone would not have the revisions to compare against.
fetch-depth: 0
- name: Resolve the Postgres service and install deps
run: |
set -eux
# Same service-IP dance as ci.yml's integration job; see the long
# comment there for why the job name must stay separator-free.
PG=$(docker ps --filter "name=compare" --filter "ancestor=pgvector/pgvector:pg16" -q | head -n1)
test -n "$PG"
PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG")
test -n "$PG_IP"
echo "PG_CONTAINER=$PG" >> "$GITHUB_ENV"
echo "DB_HOST=$PG_IP" >> "$GITHUB_ENV"
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
else
pip install -r requirements.txt
fi
# DB 1: the 87-revision chain, read out of git at `chain_ref`.
#
# A git worktree rather than a checkout, so the current tree — which is
# what we are testing — is left completely alone.
- name: Build the schema the OLD chain produces
env:
CHAIN_REF: ${{ github.event.inputs.chain_ref }}
THIS_SHA: ${{ github.sha }}
run: |
set -eux
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_chain
# Blank means "the chain in this ref", which is what you want while
# the chain is still intact — comparing the models against a PINNED
# older commit reports every migration written since as a difference.
# Pin it only after the collapse, when the tree no longer has them.
git worktree add /tmp/chain "${CHAIN_REF:-$THIS_SHA}"
ls /tmp/chain/alembic/versions/*.py | wc -l
cd /tmp/chain
DB_NAME=fc_chain alembic upgrade head
cd -
docker exec "$PG_CONTAINER" pg_dump -U fabledcurator --schema-only \
--no-owner --no-privileges -d fc_chain > chain.sql
wc -l chain.sql
# Emit the dump itself, checksummed, for local analysis. Reconciling
# the models against the deployed schema (#3275) needs the ACTUAL
# schema, not an inference from a diff — parsing table context out of
# unified-diff hunks drops every table whose CREATE TABLE line falls
# outside a hunk, which silently under-reports.
#
# base64 + sha256 for the same reason as the candidate: a plain cat
# of a file this size was truncated mid-line by the runner with the
# step still green (run 4964).
set +x
B64=$(base64 -w 120 chain.sql)
echo "===== BEGIN CHAIN SCHEMA (base64) ====="
echo "$B64"
echo "===== END CHAIN SCHEMA ====="
echo "chain-sha256: $(sha256sum chain.sql | cut -d' ' -f1)"
echo "chain-bytes: $(wc -c < chain.sql)"
set -x
# A candidate baseline, autogenerated from the models against an EMPTY
# database so every table shows up as a create. Printed for a human to
# finish — it will be missing the three raw-SQL items named at the top.
#
# Gated on the TREE, not on a workflow input. A `type: boolean` input
# read back as `github.event.inputs.generate == 'true'` silently
# evaluated false on this runner (run 4960 skipped this step entirely
# with no diagnostic) — the same `github.event.inputs` typing quirk
# build.yml already works around. The file count is the real question
# anyway: there is nothing to generate once the chain is collapsed.
- name: Autogenerate a candidate baseline
run: |
set -eux
if [ "$(ls alembic/versions/*.py | wc -l)" -le 1 ]; then
echo "already collapsed — nothing to generate"
exit 0
fi
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_gen
# Hide the existing revisions so alembic sees an empty history and
# emits the whole schema rather than a delta.
mkdir -p /tmp/versions_held
mv alembic/versions/*.py /tmp/versions_held/ 2>/dev/null || true
DB_NAME=fc_gen alembic revision --autogenerate -m "baseline" || true
# Printed rather than uploaded: ci-requirements.md records that this
# runner cannot do actions/upload-artifact@v4+, and the repo dropped
# the action entirely in 2026-05, so the job log is the retrieval
# channel actually proven here.
#
# base64, not the raw file. A plain `cat` of the ~33KB candidate was
# TRUNCATED MID-LINE by the runner on run 4964 — it stopped inside
# `sa.Column('mime', sa.String(length=128)` and carried straight on
# to the next traced command, with the step still green. A silent
# cut in the middle of a schema definition is the worst possible
# failure here, because the truncated text still looks like a
# plausible file.
#
# base64 at a fixed narrow width gives many short lines instead of
# few long ones, and — the actual point — a checksum and a line
# count that make truncation DETECTABLE rather than invisible.
set +x
F=$(ls alembic/versions/*.py | head -1)
B64=$(base64 -w 120 "$F")
echo "===== BEGIN CANDIDATE BASELINE (base64) ====="
echo "$B64"
echo "===== END CANDIDATE BASELINE ====="
echo "candidate-sha256: $(sha256sum "$F" | cut -d' ' -f1)"
echo "candidate-bytes: $(wc -c < "$F")"
echo "candidate-b64-lines: $(echo "$B64" | wc -l)"
set -x
mkdir -p /tmp/candidate
cp alembic/versions/*.py /tmp/candidate/
# Put the tree back exactly as it was; this job never mutates state.
rm -f alembic/versions/*.py
mv /tmp/versions_held/*.py alembic/versions/ 2>/dev/null || true
# DB 2: what the CURRENT tree produces.
#
# `mode: models` applies the candidate autogenerated from the MODELS
# instead, which is what answers "do the models describe the schema?" —
# the question #3275 exists because nobody had ever asked it. Under that
# mode a clean diff means autogenerate is trustworthy again.
#
# The two extensions are created by hand first. They are database
# objects, not table metadata, so no model can carry them and their
# absence is not a model defect — it is simply outside what this
# comparison is asking about.
- name: Build the schema the CURRENT tree produces
env:
MODE: ${{ github.event.inputs.mode }}
run: |
set -eux
docker exec "$PG_CONTAINER" createdb -U fabledcurator fc_base
if [ "${MODE:-chain}" = "models" ]; then
docker exec "$PG_CONTAINER" psql -U fabledcurator -d fc_base \
-c "CREATE EXTENSION IF NOT EXISTS vector" \
-c "CREATE EXTENSION IF NOT EXISTS tsm_system_rows"
mkdir -p /tmp/held
mv alembic/versions/*.py /tmp/held/
cp /tmp/candidate/*.py alembic/versions/
# Autogenerate EMITS pgvector.sqlalchemy.vector.VECTOR(...) without
# importing it, so the file it writes cannot run:
# NameError: name 'pgvector' is not defined
# Observed on run 4988, which is the proof rather than the theory.
# This is a defect in the GENERATOR, not in the models, so it is
# repaired here rather than counted as a schema difference — the
# comparison is about whether the models describe the schema.
sed -i '0,/^import sqlalchemy as sa$/s//import sqlalchemy as sa\nimport pgvector.sqlalchemy.vector/' alembic/versions/*.py
grep -n 'import pgvector' alembic/versions/*.py
# Second generator defect, same class as the missing import.
#
# base.py's naming convention includes %(constraint_name)s for ck,
# which — unlike uq/fk/ix — means the convention is applied even to
# a CheckConstraint that HAS a name. So a model declaring
# name="singleton" correctly becomes ck_ml_settings_singleton in
# the metadata. Autogenerate then writes that RENDERED name into
# the migration, and running the migration applies the convention a
# SECOND time: ck_ml_settings_ck_ml_settings_singleton.
#
# That is round-tripping damage done by the generator, not a claim
# the models make, so it is repaired here rather than counted as a
# schema difference. Undone by removing the ck_<table>_ prefix the
# convention will re-add — the exact inverse, and it only fires on
# a name that actually carries its own table's prefix.
python3 - alembic/versions/*.py <<'PYEOF'
import re, sys
table = None
for path in sys.argv[1:]:
out = []
for line in open(path):
m = re.search(r"op\.create_table\(\s*[\"']([A-Za-z0-9_]+)[\"']", line)
if m:
table = m.group(1)
if table and "CheckConstraint" in line:
prefix = f"ck_{table}_"
line = re.sub(
r"(name=[\"'])" + re.escape(prefix),
r"\1",
line,
)
out.append(line)
open(path, "w").writelines(out)
PYEOF
grep -n 'CheckConstraint' alembic/versions/*.py || true
ls alembic/versions/*.py
DB_NAME=fc_base alembic upgrade head
rm -f alembic/versions/*.py
mv /tmp/held/*.py alembic/versions/
else
ls alembic/versions/*.py | wc -l
DB_NAME=fc_base alembic upgrade head
fi
docker exec "$PG_CONTAINER" pg_dump -U fabledcurator --schema-only \
--no-owner --no-privileges -d fc_base > baseline.sql
wc -l baseline.sql
# The verdict.
#
# pg_dump orders dumpable objects by name within type, not by creation
# order, so two schemas built by different routes are directly
# comparable. Normalisation is deliberately minimal, because a filter
# that hides a real difference is the one way this check passes when it
# should fail — blank lines, SQL comments, trailing whitespace, and:
#
# \restrict / \unrestrict — a per-invocation RANDOM NONCE that newer
# pg_dump emits to fence the dump against injection during restore. It
# differs on every run by construction, so it is noise by definition,
# not a schema difference. Measured on run 4960, the control: two dumps
# of the SAME schema came back 1123 lines each and differed on exactly
# these two lines and nothing else. That control is what licenses this
# filter — it was observed to be the only false positive, rather than
# assumed to be one.
# Column ORDER inside a CREATE TABLE is compared separately from column
# CONTENT, and only content is fatal.
#
# A table built by 87 migrations has its columns in ADD COLUMN order; the
# same table built in one shot has them in declaration order. That is a
# real and permanent difference which no baseline can erase — the
# operator's existing database keeps chain order forever, a fresh install
# gets model order — so a check that fails on it would never pass and
# would teach nothing. FC reaches every column through the ORM by name,
# and `SELECT *` ordering is not depended on anywhere.
#
# So the second pass SORTS the column lines within each CREATE TABLE
# rather than DELETING them. That distinction is the whole point: sorting
# cannot hide a column that exists on one side only, or one whose type,
# nullability or default differs — those still land in the diff. A filter
# could have hidden all three.
#
# Both diffs are reported. The ordered one is informational; the
# order-insensitive one is the verdict.
- name: Diff
run: |
set -eu
norm() {
grep -vE '^\s*(--|$)' "$1" \
| grep -vE '^\\(un)?restrict ' \
| sed 's/[[:space:]]*$//'
}
norm chain.sql > a.txt
norm baseline.sql > b.txt
echo "normalised: chain=$(wc -l < a.txt) lines, current=$(wc -l < b.txt) lines"
sort_table_columns() {
python3 - "$1" <<'PYEOF'
import re, sys
lines = open(sys.argv[1]).read().splitlines()
out, block = [], None
for line in lines:
if block is not None:
# ');' on its own closes the CREATE TABLE body.
if line.strip() == ");":
out.extend(sorted(block))
out.append(line)
block = None
else:
# Drop the list comma before sorting. Only the LAST
# column lacks one, so keeping it would make every
# reordering look like a content change as well — the
# comma is punctuation, and carries no schema meaning.
block.append(line.rstrip().rstrip(","))
continue
out.append(line)
if re.match(r"CREATE TABLE .*\($", line):
block = []
if block is not None: # unterminated body: emit it rather than drop it
out.extend(block)
print("\n".join(out))
PYEOF
}
sort_table_columns a.txt > a.sorted.txt
sort_table_columns b.txt > b.sorted.txt
test "$(wc -l < a.sorted.txt)" = "$(wc -l < a.txt)"
test "$(wc -l < b.sorted.txt)" = "$(wc -l < b.txt)"
if diff -u a.txt b.txt > schema.diff; then
echo "ORDERED DIFF: identical, column order included."
else
echo "ORDERED DIFF: $(grep -cE '^[+-]' schema.diff) changed lines (informational):"
cat schema.diff
fi
echo
echo "================================================================"
echo
if diff -u a.sorted.txt b.sorted.txt > sorted.diff; then
echo "SCHEMAS MATCH — every difference above is column ORDER alone."
else
echo "SCHEMAS DIFFER — $(grep -cE '^[+-]' sorted.diff) changed lines that are NOT ordering:"
cat sorted.diff
echo
echo "The baseline is wrong, not the database. Do not stamp."
exit 1
fi
+1276 -130
View File
File diff suppressed because it is too large Load Diff
+68 -93
View File
@@ -2,7 +2,7 @@ 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: guards the extension publish path (see the job).
# - extension-version: the derived version resolves and is a shape AMO takes.
# - 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`.
@@ -35,25 +35,42 @@ jobs:
- 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/
# scripts/ likewise: release_notes.py runs only on a tag push, so a
# syntax or import error there would otherwise surface at the one
# moment nobody wants to debug a workflow.
run: ruff check backend/ tests/ alembic/ agent/ scripts/
- 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
# Guards the extension publish path, which has no self-correcting behavior.
# 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).
#
# build.yml's sign-extension job keys its AMO-signing cache purely on the
# version string in extension/package.json: if an `ext-<version>` Forgejo
# release already carries an XPI, signing is SKIPPED and that old signed XPI
# is what build-web bakes into `:latest`. Nothing in that path inspects
# whether extension/ actually changed — so a forgotten version bump ships a
# stale extension on a fully green build, silently. (AMO can't help: it 409s
# on re-signing a version, which is exactly why the cache exists.)
# 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. the derived string is one AMO will accept, checked against Mozilla's
# own published grammar rather than a loose "digits and dots"
#
# This job makes that case loud, on the dev push, instead of invisible at
# merge-to-main. It is pure git + text work — no deps, no services.
# The MAJOR.MINOR-agreement check that used to be (2) is gone with milestone
# 318 step 8: the committed version no longer seeds anything, so there is no
# hand-set part left for the two files to disagree about.
#
# 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:
@@ -61,97 +78,48 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
# Full history: the check diffs against the push's `before` SHA (or
# the PR base), which a depth-1 clone wouldn't contain.
# 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 guard
env:
BEFORE: ${{ github.event.before }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
- name: Extension version derives cleanly
run: |
set -eu
# busybox sh on the act_runner — no bashisms (family rule).
ver() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'; }
PKG=$(ver extension/package.json)
MAN=$(ver extension/manifest.json)
test -n "$PKG" || { echo "ERROR: no version found in extension/package.json"; exit 1; }
test -n "$MAN" || { echo "ERROR: no version found in extension/manifest.json"; exit 1; }
VERSION=$(sh extension/scripts/packaging.sh version)
echo "derived: $VERSION"
# (1) Unconditional: the two version strings must agree. `web-ext sign`
# reads manifest.json (package.json sits in --ignore-files and isn't
# even inside the XPI), so AMO signs MAN and Firefox installs MAN.
# build.yml keys its cache, release tag, XPI filename — and therefore
# the version /api/extension/manifest reports to the update prompt —
# on PKG. Divergence either hard-fails at AMO or ships a mislabelled
# XPI whose update prompt lies about what's installed.
if [ "$MAN" != "$PKG" ]; then
echo "ERROR: extension version mismatch."
echo " extension/manifest.json = $MAN <- what AMO signs / Firefox installs"
echo " extension/package.json = $PKG <- what CI caches, names, and reports"
echo "Set both to the same value."
# Mozilla's published grammar for AMO, transcribed verbatim from
# MDN's manifest.json/version page:
#
# ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
#
# Not the looser `^[0-9]+(\.[0-9]+)*$` this lane used to carry. That
# one passes `2026.08.29.0201`, which AMO REJECTS — a segment must be
# the single digit 0 or start 1-9 — and it also passes five segments,
# where AMO allows four. Both would surface as a failed sign with the
# version already burned: AMO 409s on re-signing, so a rejected value
# cannot be reclaimed and cannot be reused. This lane is the cheap
# place to find out. (#3138, milestone 318 step 8.)
if ! echo "$VERSION" | grep -qE '^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$'; then
echo "ERROR: derived version '$VERSION' is not a version AMO accepts."
echo "AMO's grammar: ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$"
echo "Most likely cause: a zero-padded segment (08, 0201). The rest"
echo "of the family pads; the extension must not — see packaging.sh."
exit 1
fi
# (2) If the SHIPPED extension changed, the version must have moved.
#
# Compare against MAIN, not against the previous push. The publish
# decision is made at merge-to-main against whatever ext-<version>
# already exists, so "differs from main" is the question that matters.
# Diffing against the previous dev push instead would demand a fresh
# bump on every iteration — push, tweak the extension again, and CI
# would insist on a second bump that buys nothing, inflating the
# version for no reason. On a main push there is no "main to compare
# to" yet, so fall back to that push's own before-SHA.
if [ "${GITHUB_REF##*/}" = "main" ]; then
BASE="${BEFORE:-}"
else
BASE=$(git rev-parse --verify -q origin/main 2>/dev/null || git rev-parse --verify -q main 2>/dev/null || echo "")
# PR base is the fallback when main isn't in the clone at all.
[ -n "$BASE" ] || BASE="${PR_BASE:-}"
fi
case "$BASE" in
''|0000000000000000000000000000000000000000)
echo "No usable base ref (no main in clone / first push) — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
;;
esac
if ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
echo "Base commit $BASE not in this clone — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
fi
# The exclusion list is NOT written out here — it comes from
# extension/scripts/packaging.sh, the one definition of what ships,
# shared with web-ext's --ignore-files and the derived-version patch
# count. Three hand-kept copies of that fact is how #2397 happened.
#
# `set -f` is required around the substitution: without it the shell
# globs `test/**` against the working tree and silently narrows it.
set -f
CHANGED=$(git diff --name-only "$BASE" HEAD -- extension/ $(sh extension/scripts/packaging.sh pathspec))
set +f
if [ -z "$CHANGED" ]; then
echo "No packaged extension files changed since $BASE — nothing to guard."
echo "OK: extension version $PKG"
exit 0
fi
echo "Packaged extension files changed since $BASE:"
echo "$CHANGED" | sed 's/^/ /'
PKG_OLD=$(git show "$BASE:extension/package.json" 2>/dev/null | grep -E '"version"' | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
if [ -z "$PKG_OLD" ]; then
echo "Could not read the base version — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
fi
if [ "$PKG_OLD" = "$PKG" ]; then
echo "ERROR: packaged extension files changed but the version is still $PKG."
echo "build.yml would find the existing ext-$PKG release, skip AMO signing,"
echo "and bake the OLD signed XPI into :latest — a green build shipping stale code."
echo "Bump the version in BOTH extension/package.json and extension/manifest.json."
# ...and the shape this project actually derives. AMO would happily
# take `1.0.3500147` too, so the grammar check alone would not notice
# a regression to the pre-318 shape — which orders BELOW everything
# signed since, and is unrecoverable once Firefox has the higher one.
if ! echo "$VERSION" | grep -qE '^20[0-9][0-9]\.[0-9]{1,2}\.[0-9]{1,2}\.[0-9]{1,4}$'; then
echo "ERROR: derived version '$VERSION' is not YYYY.M.D.HHMM."
echo "Rule 148's CalVer is what build.yml signs; the old"
echo "1.0.<minutes> shape would order below every ext-2026.* release."
exit 1
fi
echo "OK: extension version $PKG_OLD -> $PKG"
echo "OK: derived version $VERSION"
backend-lint-and-test:
runs-on: python-ci
@@ -164,6 +132,13 @@ jobs:
SECRET_KEY: ci_unit_test_placeholder
steps:
- uses: actions/checkout@v4
with:
# Full history for tests/test_artifact_identity.py, which derives
# each artifact's revision to check the identity scheme. On a
# depth-1 clone that derivation either fails or returns the tip sha
# — so the lane would go green while asserting nothing, which is
# the one outcome worse than a red one.
fetch-depth: 0
# 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-
+8 -3
View File
@@ -10,15 +10,20 @@ on:
paths:
- 'extension/**'
- '.forgejo/workflows/extension.yml'
# test/version.spec.js asserts ci.yml's extension-version guard never
# ignores a file web-ext actually packages, so a ci.yml-only edit can
# break this suite and must trigger it.
# test/version.spec.js asserts things ABOUT the other two workflows —
# that neither inlines the packaged-file set, and that build.yml derives
# the shipped version rather than reading it out of the repo. A
# workflow-only edit can therefore break this suite, so it has to trigger
# it. build.yml joined the list at milestone 271 step 5, when the spec
# started asserting against it.
- '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
pull_request:
branches: [main]
paths:
- 'extension/**'
- '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
workflow_dispatch:
jobs:
+80
View File
@@ -0,0 +1,80 @@
name: Release
# A `v*` tag publishes a changelog. It does NOT build anything.
#
# Milestone 318 step 2 removed the tag trigger from build.yml: by the time
# anyone tags a commit, `main` has already built and published it, and a
# rebuild would re-push `:c-<sha>` — which rule 145 forbids even when the
# source matches, since image configs carry timestamps and "same source" does
# not mean "same manifest". That left the tag with no consequence at all.
#
# This is the consequence it has instead. Step 6 put the derived version in the
# Settings footer, so an operator can say WHICH build they are running; this
# says what is IN it that was not in the one they ran last month. Both halves
# of one question (note #3127 §5).
#
# Nothing here runs on a schedule and nothing auto-tags on merge. Release tags
# are bookmarks — cut one when you will want to point at that day by name,
# otherwise don't (note #3127 §0). FC went twelve weeks between v26.06.04.0 and
# the next one and nothing was wrong. A schedule would turn an optional
# bookmark back into ceremony, which is the thing this milestone is removing.
#
# Cutting the tag is an explicit operator action under rule 2 ("`main` — never
# without explicit request", which since 2026-08-28 covers PR, merge and tag
# alike). This lane only decides what happens once they do.
#
# Requires repo secret RELEASE_TOKEN with the `write:release` scope — the same
# PAT build.yml uses for the ext-<version> XPI asset cache.
on:
push:
tags: ['v*']
# So a release body can be regenerated after the fact — the publisher PATCHes
# an existing release rather than falling through on a conflict, so re-running
# this on a tag rewrites the body instead of silently keeping the first one
# (note #3127 §6.7).
workflow_dispatch:
inputs:
tag:
description: 'Tag to (re)publish notes for'
required: true
jobs:
changelog:
runs-on: python-ci
container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps:
- uses: actions/checkout@v4
with:
# Load-bearing twice over: the previous release is found by walking
# ancestry back through the tag graph, and the cross-check against
# the derived web version calls artifacts.sh, which reads commit
# times. A shallow clone would find no previous tag and emit the
# entire history as the changelog — plausible-looking and wrong.
fetch-depth: 0
ref: ${{ github.event.inputs.tag || github.ref }}
# The `:c-<sha>` rollback refs are only real if `main` built this commit.
# The script checks that against origin/main and downgrades the claim to
# "unverified" when it cannot resolve one; fetching it here means that
# downgrade stays an actual signal instead of firing on every release.
- name: Make main's history resolvable
run: git fetch --no-tags --quiet origin +main:refs/remotes/origin/main || true
# TAG goes through the environment, not through `${{ }}` inside the
# run block. The value is operator-supplied, and an expression expanded
# into a shell line is expanded BEFORE the shell sees it — there is no
# quoting that makes that safe. On a tag push it is empty and the script
# falls back to GITHUB_REF.
- name: Publish the derived changelog
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.event.inputs.tag }}
run: |
set -eu
if [ -n "${TAG:-}" ]; then
python3 scripts/release_notes.py "$TAG"
else
python3 scripts/release_notes.py
fi
+77
View File
@@ -0,0 +1,77 @@
# Contributing
FabledCurator is developed by a single maintainer for their own use, and
published because it may be useful to others. That shapes what contribution
looks like here.
**Issues are welcome** — bug reports, and questions about running it, are
genuinely useful and often the fastest way to find out that something is
broken outside the one environment it was built in.
**Open an issue before writing a pull request.** Not as a formality: the
project has opinions that are not obvious from the code, and it is unpleasant
for everyone when a finished patch turns out to conflict with one. A short
issue first costs you nothing and may save you an evening.
**Contributions are licensed under the AGPL-3.0**, like the rest of the
project. By submitting one you agree it ships under that licence. There is no
CLA and no copyright assignment.
## Running it for development
```bash
docker compose up -d # UI on http://localhost:8080
```
The dev override (`docker-compose.override.yml`) is auto-merged and builds the
app images locally from source, so this needs no `.env` and no registry
access. Postgres and Redis ports are exposed on the host.
## What CI checks
Every push runs these, and they are the definition of done for a change:
```bash
ruff check backend/ tests/ alembic/ agent/ scripts/ # lint (and import order)
pytest tests/ -m "not integration" # backend unit tests
pytest tests/ -m integration # needs pgvector + redis
cd frontend && npm run test:unit && npm run build # frontend
```
The integration lane builds its schema by running the real migrations
(`alembic upgrade head`), never from ORM metadata — so a migration that does
not apply cleanly fails CI rather than being discovered later.
Note for the linter: ruff's isort runs with `order-by-type`, which sorts
ALL-CAPS names ahead of CamelCase. `from sqlalchemy import JSON, DateTime, ...`
is correct; putting `JSON` alphabetically between `Integer` and `String` is
not. This catches people out.
## Database changes
The ORM models and the migration chain must agree. This is enforced, and it is
enforced because they silently diverged for a long time and nobody noticed
until they were compared: the models were missing indexes, defaults and
uniqueness guarantees that only ever existed inside a migration, which made
`alembic revision --autogenerate` actively unsafe to run.
So: if you change a model, write the migration; if you write a migration,
change the model to match. Both, in the same commit.
Adding a value to a CHECK-constrained column means swapping the constraint in
the same change — the constraint is not documentation, and a new value without
it fails at insert time.
## Branch model
`dev` is where work happens. `main` is production and is only reached by a
merge from `dev`, never pushed to directly. If you are sending a pull request,
target `dev`.
## Style
Match the surrounding code. The one convention worth stating explicitly is
that comments here explain *why*, especially where a choice looks wrong at a
glance — a comment recording which migration a constraint came from, or why a
default is a `text()` rather than a string, is the kind that has repeatedly
turned out to be worth its space.
+23
View File
@@ -47,6 +47,29 @@ RUN chmod +x entrypoint.sh
COPY --from=frontend-builder /build/dist ./frontend/dist
# Which channel this image belongs to — `dev` or `main` (milestone 271 step 7).
# build.yml passes it; /api/extension/manifest reports it beside the version so
# an operator can tell which channel an install came from without the channel
# ever touching the version string.
#
# Empty by default, deliberately: a locally-built image then reports NO channel
# rather than claiming to be one, and the manifest omits the field entirely —
# indistinguishable from an image built before the field existed, which is
# exactly the shape every reader already has to handle.
#
# Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and
# these are the values that differ between builds of otherwise identical
# source — put them any earlier and the two channels could never share a
# cached pip install.
#
# FC_VERSION is what the instance reports about itself in the UI. Since
# milestone 318 stopped publishing version image tags, that self-report is
# the only answer to "which build is this?" — nothing else names it.
ARG FC_CHANNEL=""
ENV FC_CHANNEL=${FC_CHANNEL}
ARG FC_VERSION=""
ENV FC_VERSION=${FC_VERSION}
EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"]
+661
View File
@@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
+65 -7
View File
@@ -10,6 +10,35 @@ In production. `main` is continuously deployed — every merge to `main` builds
and publishes `:latest` images, so whatever is on `main` is what is running.
Day-to-day work happens on `dev`, which publishes `:dev` images.
## Versions and tags
Three image tags exist, and no others:
| Tag | Branch | Meaning |
| --- | --- | --- |
| `:latest` | `main` | Production. Moves on every merge. |
| `:c-<sha>` | `main` | Immutable — the rollback unit, all three images together. |
| `:dev` | `dev` | The rolling test channel. Moves on every push. |
There are deliberately **no version tags**. Nothing pins one, and a per-build
name nobody reads is upkeep for a model FC does not run (family rule 145; the
reasoning is note #3127 §5). Rolling back is `docker pull …:c-<sha>`.
Each artifact still has a version, derived rather than chosen: the commit time
of the newest change to that artifact's *own* shipped files, as
`YYYY.MM.DD.HHMM` UTC (rule 148). Four artifacts, four independent versions —
a push touching only `agent/` re-versions the agent and leaves web and ml
alone, and CI skips the builds whose content did not move.
Because no registry name carries it, the running instance's own report is the
only answer to "which build is this?". The foot of Settings shows
`FabledCurator 2026.08.29.0201 · dev`, and `/api/health` returns the same two
fields.
Release tags are optional bookmarks — FC went twelve weeks without one and
nothing was wrong. Pushing `v<version>` publishes a Forgejo release listing the
commits since the previous tag; it builds no image.
## What's in here
Five deployable pieces, built by `.forgejo/workflows/build.yml`:
@@ -19,7 +48,7 @@ Five deployable pieces, built by `.forgejo/workflows/build.yml`:
| **Web / workers** | `Dockerfile` | `fabledcurator` | Quart API + the built Vue SPA in one image. `entrypoint.sh` picks the role: `web`, `worker`, `scheduler`. The `maintenance-long` service is a second `worker` pinned to the long-running maintenance queue. |
| **ML worker** | `Dockerfile.ml` | `fabledcurator-ml` | Same app, plus `requirements-ml.txt` — tagging and embedding models that run in-container. |
| **GPU agent** | `agent/Dockerfile` | `fabledcurator-agent` | Optional desktop-GPU worker (`agent/`). Leases jobs over **HTTP only** — never touches the database or Redis. Run it for a burst, stop it to reclaim the card. See `agent/README.md`. |
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on `main` only, then bundled into the web image and served from Settings → Maintenance. See `extension/README.md`. |
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both `dev` and `main` (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See `extension/README.md`. |
| **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
## Quick start
@@ -40,9 +69,19 @@ or a `.env` file (see `.env.example` for the variable names) and use:
```bash
docker compose -f docker-compose.yml up -d
# (skips the override so containers pull registry images)
# (skips the dev override, so containers pull published :latest images)
```
`-f` is doing real work there: it tells Compose to use *only* that file, which
skips `docker-compose.override.yml` and its local builds. What you get is the
`:latest` images — the stable channel, built from `main`. This is the install
path, and it is the one to use if you are running FabledCurator rather than
working on it.
`:dev` is the other channel: rebuilt from the `dev` branch several times a day,
bleeding edge, no stability promise. Nothing in this repo points an installer at
it, and nothing should.
The GPU agent is deployed separately, on the machine with the card —
`agent/docker-compose.yml`, not this stack.
@@ -52,9 +91,10 @@ FabledCurator is designed to run inside a self-hosted homelab environment over p
## CI / Forgejo setup
Three workflows: `ci.yml` (lint, extension-version guard, backend unit tests,
Four workflows: `ci.yml` (lint, extension-version check, backend unit tests,
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
content verification), and `build.yml` (sign + publish).
content verification), `build.yml` (sign + publish), and `release.yml`, which
runs only on a `v*` tag and publishes a changelog without building anything.
**The toolchain each job runs in is its `container.image`, not its `runs-on`
label.** `runs-on: python-ci` only schedules the job onto a runner; every job
@@ -71,9 +111,27 @@ The repo expects one secret:
Generate at https://git.fabledsword.com/user/settings/applications. The injected `GITHUB_TOKEN` cannot be used because it lacks `write:package`.
AMO signing additionally needs `MOZILLA_AMO_JWT_KEY` / `MOZILLA_AMO_JWT_SECRET`; it runs on
`main` only and is cached per version, since AMO rejects a re-signed version.
AMO signing additionally needs `MOZILLA_AMO_JWT_KEY` / `MOZILLA_AMO_JWT_SECRET`.
It runs on **both** channels and is cached per version: because the version is
derived from commit time, `dev` and `main` derive the same number for the same
source, so `main` finds `dev`'s signature already cached and makes no second AMO
call. That cache is why signing must be one-shot — AMO rejects a re-signed
version.
## License
Personal project; use at your own discretion.
**GNU Affero General Public License v3.0** — see [LICENSE](LICENSE).
You may run, study, modify and redistribute this software. The condition is
reciprocity: if you distribute a modified version, or **run one as a network
service that other people use**, you must offer those users the corresponding
source under the same licence. That second clause (AGPL §13) is the reason this
licence rather than the GPL — for a self-hosted web application, "distribution"
otherwise never happens, and the obligation would never bite.
Running an unmodified copy for yourself, your household or your organisation
carries no obligation at all. Neither does modifying it privately. The licence
asks something of you only when you hand your modified version to others.
Contributions ship under the same licence — see [CONTRIBUTING](CONTRIBUTING.md).
Security reports: [SECURITY.md](SECURITY.md).
+63
View File
@@ -0,0 +1,63 @@
# Security Policy
## Reporting a vulnerability
**Please do not put vulnerability details in a public issue.**
This project has no private disclosure channel yet. Until it does, open an
issue on the repository that says only that you have a security report — no
reproduction steps, no affected endpoint, no payload — and a maintainer will
reply with a private contact to send the details to.
That is a deliberately awkward first step, and it exists because the
alternative is worse: an issue tracker is public the moment it is written to,
and every self-hosted instance stays vulnerable until its operator has had a
chance to update.
Please include, once you have a private channel:
- what an attacker can do, and what access they need to start
- the version or commit you tested
- reproduction steps
## Scope — what this software actually handles
FabledCurator is self-hosted and holds things worth stating plainly, because
they shape what counts as a serious bug here:
- **Platform credentials.** The app captures and stores session cookies for
third-party subscription sites (Patreon, SubscribeStar, Pixiv) so it can
download on the operator's behalf. These are live credentials for accounts
that usually carry a payment method. Anything that discloses them, decrypts
them, or lets one user of a shared instance read another's is high severity.
- **An extension API key.** The Firefox extension authenticates to the backend
with a shared key. Anything that leaks it or lets it be bypassed is a way in.
- **A multi-user sharing ACL.** Instances can be shared. A bug that lets one
account see content another has not shared is an access-control failure, not
a cosmetic one.
- **Arbitrary media from the internet.** Downloaded files are decoded, hashed,
thumbnailed and fed to ML models. Anything that turns a hostile file into
code execution is in scope.
## Deployment posture — read this before reporting
FabledCurator is designed to run **inside a private network, over plain HTTP**.
It does not terminate TLS, redirect to HTTPS, or set HSTS; if you want
transport security, terminate it at your reverse proxy. This is a documented
design decision, not an oversight.
Reports that reduce to "the application is served over HTTP" or "there is no
HSTS header" describe that decision rather than a vulnerability. Reports that
an authenticated operator can cause the software to do something destructive
are usually also by design — the operator is the administrator of their own
instance.
What remains in scope is everything that crosses a boundary the software is
supposed to hold: between one user and another, between an unauthenticated
visitor and any of it, and between untrusted downloaded content and the host.
## Supported versions
Fixes land on the `main` branch and reach the `:latest` image. There are no
maintained release branches — the supported version is the current one, and
the remedy for a security issue is to update.
@@ -0,0 +1,128 @@
"""Reconcile the database with what the models have always claimed (#3275).
Milestone 328 discovered ~130 places where the ORM models and the deployed
schema disagreed. Almost all of them were the MODEL being wrong — missing
`server_default`s, indexes and CHECK constraints that only ever existed in a
migration — and those are fixed in the model files with no DDL at all, because
the database already had them.
This migration carries the remainder — the two places where DDL is actually
needed, because the database is what is wrong.
`tag.fandom_id` is declared `index=True` on the model, but no migration ever
created that index. Every autogenerate run since would have proposed adding
it; nobody ran one, so the model and the database simply drifted apart and
stayed that way.
Deliberately NOT in this migration: anything about `image_record.sha256`. An
earlier draft of this file claimed sha256 was not unique in the database and
that duplicate rows were therefore possible. That was WRONG, and it was wrong
because it was read off `op.create_index("ix_image_record_sha256", ...)` at
0001 line 151 without reading line 149 two lines above it:
sa.UniqueConstraint("sha256", name="uq_image_record_sha256"),
Uniqueness has been enforced since the initial schema. The database simply
expresses it as a CONSTRAINT plus a separate non-unique lookup index, where
the model expressed it as one `unique=True, index=True` column — the same
guarantee built from different objects, which is why the two schemas did not
line up. The model now declares the constraint and the plain index separately,
so it describes what is actually there. No DDL is needed for it.
Also here: six CHECK constraints whose names carry their table prefix TWICE.
`base.py`'s naming convention is `ck_%(table_name)s_%(constraint_name)s`, and
unlike the uq/fk/ix entries it applies even to a constraint that already has a
name. Six migrations passed an already-prefixed name, so the convention
prefixed it again:
ck_external_link_ck_external_link_host
ck_external_link_ck_external_link_status
ck_import_settings_ck_import_settings_singleton
ck_ml_settings_ck_ml_settings_singleton
ck_post_ck_post_translation_override
ck_tag_ck_tag_fandom_requires_character
Nothing reads a CHECK constraint by name, so this has never done any harm —
but it is exactly the development-era residue the collapsed baseline exists to
leave behind, and a public schema should not ship it. The models now declare
bare names, which the convention renders into the single-prefix form; this
renames the deployed constraints to match.
RENAME CONSTRAINT is a catalog-only operation: no table scan, no rewrite, no
validation of existing rows. It takes a brief ACCESS EXCLUSIVE lock and
returns. That is why this is safe to do on `post` and `tag`, which are the two
large tables in the schema.
Revision ID: 0088
Revises: 0087
Create Date: 2026-08-30
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0088"
down_revision: Union[str, None] = "0087"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (table, doubled name, single-prefix name)
#
# Six, not the four a first read of the migrations turned up. The list that
# settles it is the one extracted from the chain's pg_dump by matching
# `ck_(\w+?)_ck_\1_` — reading the migrations by eye missed external_link
# twice over, in the same way an earlier pass missed a UNIQUE constraint two
# lines above the index it was looking at (see the sha256 note above).
DOUBLED_CHECKS = (
("external_link", "ck_external_link_ck_external_link_host",
"ck_external_link_host"),
("external_link", "ck_external_link_ck_external_link_status",
"ck_external_link_status"),
("import_settings", "ck_import_settings_ck_import_settings_singleton",
"ck_import_settings_singleton"),
("ml_settings", "ck_ml_settings_ck_ml_settings_singleton",
"ck_ml_settings_singleton"),
("post", "ck_post_ck_post_translation_override",
"ck_post_translation_override"),
("tag", "ck_tag_ck_tag_fandom_requires_character",
"ck_tag_fandom_requires_character"),
)
def _rename_check(table: str, old: str, new: str) -> None:
# Guarded on pg_constraint rather than run bare: a database built from the
# models (a fresh install, or the CI integration schema) already has the
# single-prefix name, and this migration must be a no-op there rather than
# an error. Same reasoning as the CREATE INDEX IF NOT EXISTS below.
op.execute(
f"""
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = '{old}' AND conrelid = '{table}'::regclass
) THEN
ALTER TABLE {table} RENAME CONSTRAINT {old} TO {new};
END IF;
END $$;
"""
)
def upgrade() -> None:
# IF NOT EXISTS because the index is what the model already asks for: any
# database built from metadata rather than from this chain will have it,
# and this migration must be a no-op there rather than an error.
op.execute("CREATE INDEX IF NOT EXISTS ix_tag_fandom_id ON tag (fandom_id)")
for table, old, new in DOUBLED_CHECKS:
_rename_check(table, old, new)
def downgrade() -> None:
for table, old, new in DOUBLED_CHECKS:
_rename_check(table, new, old)
op.execute("DROP INDEX IF EXISTS ix_tag_fandom_id")
+120
View File
@@ -0,0 +1,120 @@
"""Index the seven unindexed FKs; drop the seven redundant indexes (#3300, #3301).
Found by a structural sweep of the deployed schema done AFTER 0088 brought the
models and the migration chain into exact agreement. That agreement is what
0088 achieved, and it is worth being precise about what it does NOT prove: a
models-vs-chain diff shows the two describe the same schema. It says nothing
about whether that schema is right. Everything here was wrong in BOTH, which is
exactly the class of problem the reconciliation could not see.
## Added: seven FK indexes
`image_tag.tag_id` is the one that matters. The table's only index is
PRIMARY KEY (image_record_id, tag_id), which leads with the wrong column for
the two hottest things done with it:
* the gallery's tag filter — services/tag_query.py builds
`image_tag.c.tag_id == tid` (and `.in_(tids)`) on every tag-scoped browse;
* ON DELETE CASCADE from `tag` — deleting or merging a tag makes Postgres
find that tag's rows before it can remove them.
Both had to scan the largest table in the schema. The other six are the same
shape on much smaller tables; `presentation_review.tag_id` is the notable one,
since it also CASCADEs.
## Dropped: seven redundant indexes
`ix_image_record_sha256` was an exact duplicate. A UNIQUE constraint builds its
own index, so `uq_image_record_sha256` already covered the column and
`image_record` carried two btrees on `sha256` — on the highest-insert-rate
table in the system.
The other six are single-column indexes that a later composite superseded
without the narrow one being retired. A btree on (a, b) already serves lookups
on `a`, so each was pure write amplification. `task_run` and `backup_run` are
append-heavy operational logs, which is where that cost lands hardest.
Note for anyone reading 0088 next to this: 0088 deliberately taught the models
to declare BOTH sha256 indexes, so they would describe reality. That was right.
This migration changes the reality instead, and the models change with it.
## CONCURRENTLY, and why this migration has no transaction
`CREATE INDEX` takes an ACCESS EXCLUSIVE lock for the whole build, which on
`image_tag` means stalling every write for as long as it takes. CONCURRENTLY
builds without blocking writers, at the cost of two table passes and an
inability to run inside a transaction — hence `autocommit_block()`.
The consequence to know about: this migration is NOT atomic. If it fails
partway, the work already done stays done. Every statement is therefore written
IF NOT EXISTS / IF EXISTS so that re-running it after a failure is safe rather
than an error.
A failed CONCURRENTLY build also leaves an INVALID index behind — it is not
used by the planner and not repaired automatically. Find them with:
SELECT c.relname FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisvalid;
Drop what that returns and re-run; nothing else is needed.
Revision ID: 0089
Revises: 0088
Create Date: 2026-08-31
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0089"
down_revision: Union[str, None] = "0088"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
# (index name, table, column) — names match what the models render under
# base.py's naming convention, so autogenerate stays quiet after this.
MISSING_FK_INDEXES = (
("ix_image_tag_tag_id", "image_tag", "tag_id"),
("ix_presentation_review_tag_id", "presentation_review", "tag_id"),
("ix_presentation_review_conflict_tag_id", "presentation_review", "conflict_tag_id"),
("ix_import_task_result_image_id", "import_task", "result_image_id"),
("ix_external_link_attachment_id", "external_link", "attachment_id"),
("ix_character_prototype_region_id", "character_prototype", "region_id"),
("ix_backup_run_restored_from_id", "backup_run", "restored_from_id"),
)
# (index name, table, column) — redundant; the second element of each pair in
# the docstring is what still covers the column after the drop.
REDUNDANT_INDEXES = (
("ix_image_record_sha256", "image_record", "sha256"),
("ix_backup_run_kind", "backup_run", "kind"),
("ix_backup_run_status", "backup_run", "status"),
("ix_task_run_queue", "task_run", "queue"),
("ix_task_run_status", "task_run", "status"),
("ix_task_run_task_name", "task_run", "task_name"),
("ix_external_link_post_id", "external_link", "post_id"),
)
def upgrade() -> None:
with op.get_context().autocommit_block():
for name, table, column in MISSING_FK_INDEXES:
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} "
f"ON {table} ({column})"
)
for name, _table, _column in REDUNDANT_INDEXES:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
def downgrade() -> None:
with op.get_context().autocommit_block():
for name, table, column in REDUNDANT_INDEXES:
op.execute(
f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {name} "
f"ON {table} ({column})"
)
for name, _table, _column in MISSING_FK_INDEXES:
op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {name}")
+28 -1
View File
@@ -13,6 +13,7 @@ from pathlib import Path
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from ..build_info import FC_CHANNEL as _FC_CHANNEL
from ..extensions import get_session
from ..models import AppSetting
from ..services.extension_service import (
@@ -31,6 +32,15 @@ XPI_DIR = Path("/app/frontend/dist/extension")
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$")
# Which channel this image belongs to — "dev" or "main" — baked in at build
# time (milestone 271 step 7). Read from build_info rather than the environment
# a second time: /api/health reports the same value, and two independent
# `os.environ.get` calls are two things that can drift.
#
# Still bound as a module-level name here, so tests monkeypatch
# `extension.FC_CHANNEL` exactly as they did before, same as XPI_DIR above.
FC_CHANNEL = _FC_CHANNEL
async def _ext_key_required(session) -> bool:
"""Unlike /api/credentials (which accepts the browser path with no
@@ -133,13 +143,30 @@ def _read_manifest_sync() -> dict | None:
return None
versioned.sort(key=lambda p: p.stat().st_mtime)
latest = versioned[-1]
return {
info = {
"installed": True,
"version": _extract_version(latest.name),
"xpi_url": f"/extension/{latest.name}",
"latest_url": "/extension/fabledcurator-latest.xpi",
"sha256": _sha256(latest),
}
# The channel goes BESIDE the version, never inside it. A `-dev` suffix is
# what silently disabled the dev channel in the sibling project this design
# comes from: the comparator returned nothing for a non-integer segment, so
# every dev version compared equal and "no update available" became
# indistinguishable from "I cannot read this version".
#
# Omitted rather than defaulted when unset. Absence already has a meaning
# every reader must handle — an image built before this field existed says
# exactly the same thing by not having the key — so a blank channel reuses
# that path instead of inventing a second "unknown" spelling.
#
# Reported verbatim, not validated against {"dev", "main"}: if an image
# declares something else, showing what it actually claims is more useful
# to whoever is debugging it than dropping the value on the floor.
if FC_CHANNEL:
info["channel"] = FC_CHANNEL
return info
@extension_bp.route("/manifest", methods=["GET"])
+17 -2
View File
@@ -1,5 +1,20 @@
"""Health endpoint — no DB or Redis touch; just liveness."""
"""Health endpoint — no DB or Redis touch; liveness, plus the build's identity.
The identity rides here rather than on a route of its own because it answers
at the same cost: two module constants, no I/O, nothing that can be slow or
fail. It is also already fetched app-wide — TopNav calls `refreshHealth` on
mount — so a separate endpoint would mean a second request for two strings.
Both fields are OMITTED when unset rather than sent empty. See build_info.
"""
from ..build_info import FC_CHANNEL, FC_VERSION
async def get_health():
return {"status": "ok"}, 200
body = {"status": "ok"}
if FC_VERSION:
body["version"] = FC_VERSION
if FC_CHANNEL:
body["channel"] = FC_CHANNEL
return body, 200
+30
View File
@@ -0,0 +1,30 @@
"""What this build IS — stamped at image build time, not configurable.
Deliberately separate from `config.py`. Those are operator settings, read from
the environment and meant to be changed. These describe the artifact itself and
are baked in by CI (the `FC_VERSION` / `FC_CHANNEL` build args); an operator
setting them by hand is not a supported thing to do, it is just how a value
gets from the build into the running process.
**Absent rather than empty when unknown.** A locally-built image has no version,
and neither did any image predating the field — one spelling of "cannot say",
which every reader already has to handle, instead of a second one to
special-case (note #3127 §7).
**Why this matters more than it used to.** Milestone 318 stopped publishing
version image tags, so a running instance's self-report is now the *only*
answer to "which build is this?" — there is no registry name left to check it
against. A wrong value here has nothing to contradict it. That is why the UI
renders `unknown` rather than a blank or a plausible default: an empty footer
reads as "no version", which is a different and false claim.
The channel lives BESIDE the version and is never folded into it (rule 149).
A `-dev` suffix would be parsed by the extension's comparator as a segment
worth 0, making every dev build compare equal to every other — issue #2993's
exact failure.
"""
import os
FC_VERSION = os.environ.get("FC_VERSION", "").strip()
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
+2 -2
View File
@@ -27,10 +27,10 @@ class Artist(Base):
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
# True once a Source is attached; flips false if all sources removed.
is_subscription: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_subscription: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
# Per-artist scheduling overrides; null means "use global default".
auto_check: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
auto_check: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
check_interval_seconds: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
+17 -4
View File
@@ -20,7 +20,7 @@ feedback_check_existing_enums):
from datetime import datetime
from sqlalchemy import JSON, BigInteger, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import JSON, BigInteger, DateTime, ForeignKey, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -29,10 +29,21 @@ from .base import Base
class BackupRun(Base):
__tablename__ = "backup_run"
__table_args__ = (
# alembic 0017: reporting indexes, never declared on the model (#3275).
Index("ix_backup_run_kind_started", "kind", text("started_at DESC")),
Index("ix_backup_run_status_finished", "status", text("finished_at DESC")),
Index("ix_backup_run_tag_partial", "tag", postgresql_where=text("tag IS NOT NULL")),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
kind: Mapped[str] = mapped_column(String(16), nullable=False, index=True)
# No index=True: ix_backup_run_kind_started (above) already leads with
# `kind`, so a single-column index on it was pure write cost (#3301).
kind: Mapped[str] = mapped_column(String(16), nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending", index=True,
# No index=True — ix_backup_run_status_finished leads with `status`.
String(16), nullable=False, default="pending",
server_default="pending",
)
tag: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
triggered_by: Mapped[str] = mapped_column(String(32), nullable=False)
@@ -49,7 +60,9 @@ class BackupRun(Base):
manifest: Mapped[dict] = mapped_column(
JSON, nullable=False, default=dict, server_default="{}",
)
# Self-referential FK, unindexed until 0089 (#3300): SET NULL has to find
# the rows pointing at a deleted run before it can null them.
restored_from_id: Mapped[int | None] = mapped_column(
ForeignKey("backup_run.id", ondelete="SET NULL"),
nullable=True,
nullable=True, index=True,
)
+3 -1
View File
@@ -40,8 +40,10 @@ class CharacterPrototype(Base):
)
# Provenance: the region this vector was copied from. SET NULL so pruning a
# region doesn't delete the prototype mid-cycle (the next refresh reconciles).
# index=True added in 0089 — the FK was unindexed (#3300).
region_id: Mapped[int | None] = mapped_column(
ForeignKey("image_region.id", ondelete="SET NULL"), nullable=True
ForeignKey("image_region.id", ondelete="SET NULL"), nullable=True,
index=True,
)
+2 -2
View File
@@ -25,8 +25,8 @@ class DownloadEvent(Base):
DateTime(timezone=True), nullable=False, server_default=func.now()
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
bytes_downloaded: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
files_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
bytes_downloaded: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0, server_default="0")
files_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
error: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_: Mapped[dict] = mapped_column(
"metadata", JSONB, nullable=False, default=dict,
+20 -1
View File
@@ -16,6 +16,7 @@ doesn't delete the link record).
from datetime import datetime
from sqlalchemy import (
CheckConstraint,
DateTime,
Float,
ForeignKey,
@@ -38,15 +39,33 @@ STATUSES = ("pending", "downloading", "downloaded", "failed", "skipped", "dead")
class ExternalLink(Base):
__tablename__ = "external_link"
__table_args__ = (
# alembic 0028 enum CHECKs. Rule 36 territory: a new host or status value
# needs its constraint swapped in the same migration (#3275).
CheckConstraint(
"host IN ('mega', 'gdrive', 'mediafire', 'dropbox', 'pixeldrain')",
# Bare name: Base.metadata's naming convention prepends
# ck_<table>_. Pre-prefixing it here doubles the prefix — see
# alembic 0088, which renames the four constraints that shipped
# that way (#3275).
name="host",
),
CheckConstraint(
"status IN ('pending', 'downloading', 'downloaded', 'failed', 'skipped', 'dead')",
name="status",
),
# One row per (post, url). The full url (incl. #fragment) is the identity
# — the same file linked twice in a post collapses to one row.
Index("uq_external_link_post_url", "post_id", "url", unique=True),
Index("ix_external_link_status", "status"),
# Unindexed FK (#3300).
Index("ix_external_link_attachment_id", "attachment_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# No index=True: uq_external_link_post_url (post_id, url) already leads
# with post_id (#3301).
post_id: Mapped[int] = mapped_column(
ForeignKey("post.id", ondelete="CASCADE"), nullable=False, index=True
ForeignKey("post.id", ondelete="CASCADE"), nullable=False
)
artist_id: Mapped[int | None] = mapped_column(
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
+3 -2
View File
@@ -50,7 +50,8 @@ class GpuJob(Base):
# What to compute, e.g. 'ccip' (detect figures + CCIP-embed) or 'siglip_region'.
task: Mapped[str] = mapped_column(String(32), nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending", index=True
String(16), nullable=False, default="pending", index=True,
server_default="pending",
)
# pending | leased | done | error
lease_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
@@ -60,7 +61,7 @@ class GpuJob(Base):
lease_expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Triage verdict for an ERRORED job (#125): NULL = not yet probed;
# 'defect' = the integrity probe says the FILE itself is bad (surfaced for
+3 -2
View File
@@ -24,10 +24,11 @@ class HeadAutoApplyRun(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# dry_run=True is a PREVIEW: scores + counts what WOULD apply, writes nothing
# (preview/apply parity, rule 93).
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
dry_run: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", index=True
String(16), nullable=False, default="running", index=True,
server_default="running",
)
# running | ready | error
started_at: Mapped[datetime] = mapped_column(
+2 -2
View File
@@ -24,9 +24,9 @@ class HeadMetric(Base):
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True
)
# An auto-applied (source='head_auto') tag the operator later REMOVED.
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
# A tag with a head that the operator added by HAND (the head missed it).
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+11 -5
View File
@@ -19,8 +19,14 @@ class HeadMetricsSnapshot(Base):
__tablename__ = "head_metrics_snapshot"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), index=True
# Nullable, matching alembic 0060, which declared this column without
# `nullable=False`. The model had it as `Mapped[int]` — NOT NULL — which
# was simply never true of the database (#3275). Left nullable rather than
# tightened: a snapshot of a tag that is later hard-deleted is a row worth
# keeping, and the FK is ON DELETE CASCADE, so tightening it would only
# change behaviour, not correct a bug.
tag_id: Mapped[int | None] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=True, index=True
)
# Denormalized so a snapshot stays readable even if the tag is later renamed.
name: Mapped[str] = mapped_column(String(255), nullable=False)
@@ -28,9 +34,9 @@ class HeadMetricsSnapshot(Base):
DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
)
# Current count of source='head_auto' applications still standing.
n_auto_applied: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
n_auto_applied: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
n_misfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
n_underfires: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
# The head's measured quality at snapshot time (null if no head exists).
ap: Mapped[float | None] = mapped_column(Float, nullable=True)
precision_cv: Mapped[float | None] = mapped_column(Float, nullable=True)
+2 -1
View File
@@ -24,7 +24,8 @@ class HeadTrainingRun(Base):
# Training parameters: {min_positives, neg_ratio, precision_target, ...}.
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", index=True
String(16), nullable=False, default="running", index=True,
server_default="running",
)
# running | ready | error
started_at: Mapped[datetime] = mapped_column(
+8 -1
View File
@@ -47,8 +47,15 @@ class ImageProvenance(Base):
# attachment on the post. NULL for loose downloads and pre-backfill rows.
# SET NULL so deleting the archive attachment never destroys the (image,
# post) edge — it just forgets which archive it came from.
# FK named explicitly: the convention renders this
# `fk_image_provenance_from_attachment_id_post_attachment`, but alembic
# 0055 created it as `fk_image_provenance_from_attachment` (#3275).
from_attachment_id: Mapped[int | None] = mapped_column(
ForeignKey("post_attachment.id", ondelete="SET NULL"),
ForeignKey(
"post_attachment.id",
ondelete="SET NULL",
name="fk_image_provenance_from_attachment",
),
nullable=True, index=True,
)
captured_metadata: Mapped[dict | None] = mapped_column(JSON, nullable=True)
+41 -3
View File
@@ -14,10 +14,13 @@ from sqlalchemy import (
Enum,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
@@ -29,11 +32,38 @@ ORIGIN_CHOICES = ("downloaded", "imported_filesystem", "uploaded")
class ImageRecord(Base):
__tablename__ = "image_record"
__table_args__ = (
# alembic 0001. The database enforces sha256 uniqueness with a
# CONSTRAINT and carries a SEPARATE non-unique btree index; the model
# said `unique=True, index=True`, which collapses both into a single
# UNIQUE index under a different name. Same guarantee either way, but
# not the same objects, so autogenerate saw a drop and an add (#3275).
UniqueConstraint("sha256", name="uq_image_record_sha256"),
# alembic 0036, and the last thing in this schema that lived only in a
# migration. SQLAlchemy CAN express an hnsw index with an operator
# class, so there is no reason for it to be invisible to the models —
# and its absence was the quietest failure of the lot: everything
# works, similarity search just silently stops using an index.
Index(
"ix_image_record_siglip_hnsw",
"siglip_embedding",
postgresql_using="hnsw",
postgresql_ops={"siglip_embedding": "vector_cosine_ops"},
),
# alembic 0035/0071: the date-ordered browse indexes (#3275).
Index("ix_image_record_effective_date", text("effective_date DESC"), text("id DESC")),
Index("ix_image_record_earliest_post_date", text("earliest_post_date DESC"), text("id DESC")),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# On-disk identity
path: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
sha256: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
# Neither unique= nor index=: uq_image_record_sha256 in __table_args__
# above creates its own index, and the separate ix_image_record_sha256
# that 0001 also built was an exact duplicate of it — dropped in 0089
# (#3301). Lookups by sha256 use the constraint's index.
sha256: Mapped[str] = mapped_column(String(64), nullable=False)
phash: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
mime: Mapped[str] = mapped_column(String(64), nullable=False)
@@ -47,7 +77,8 @@ class ImageRecord(Base):
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
integrity_status: Mapped[str] = mapped_column(
String(24), nullable=False, default="unknown", index=True
String(24), nullable=False, default="unknown", index=True,
server_default="unknown",
)
# Thumbnail (populated by FC-2)
@@ -72,8 +103,15 @@ class ImageRecord(Base):
)
# FC-2d-vii-c: canonical per-image artist (the single source of truth
# for attribution; provenance posts remain lineage detail).
# FK named explicitly: the naming convention renders this
# `fk_image_record_artist_id_artist`, but alembic 0008 created it as
# `fk_image_record_artist_id` (#3275).
artist_id: Mapped[int | None] = mapped_column(
ForeignKey("artist.id", ondelete="SET NULL"), nullable=True, index=True
ForeignKey(
"artist.id", ondelete="SET NULL", name="fk_image_record_artist_id"
),
nullable=True,
index=True,
)
# ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
+7 -7
View File
@@ -21,17 +21,17 @@ class ImportBatch(Base):
)
finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
total_files: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
total_files: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
imported: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
skipped: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
attachments: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
# Deep-scan only: count of already-imported files whose sidecar metadata
# got re-applied this run (post/source/provenance upsert). Stays 0 on
# quick-scan batches. See `Importer.import_one(deep_scan=True)`.
refreshed: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
refreshed: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="running", index=True, server_default="running")
# running | complete | cancelled
tasks = relationship("ImportTask", back_populates="batch", cascade="all, delete-orphan")
+42 -16
View File
@@ -4,7 +4,15 @@ Enforced as a single row via a CHECK (id = 1) constraint. The application
always SELECTs id=1 and never inserts/deletes after the initial migration.
"""
from sqlalchemy import Boolean, CheckConstraint, Float, Integer, Text, select
from sqlalchemy import (
Boolean,
CheckConstraint,
Float,
Integer,
Text,
select,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -14,63 +22,79 @@ class ImportSettings(Base):
__tablename__ = "import_settings"
# Bare constraint name — Base.metadata's naming convention applies the
# ck_<table>_<name> prefix, producing the final ck_import_settings_singleton.
# Bare name — Base.metadata's naming convention prepends ck_<table>_,
# producing ck_import_settings_singleton. The chain shipped the DOUBLED
# ck_import_settings_ck_import_settings_singleton, because the migration
# pre-prefixed the name and the convention prefixed it again; alembic
# 0088 renames it to what this line has always produced (#3275).
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
import_scan_path: Mapped[str] = mapped_column(Text, nullable=False, default="/import")
import_scan_path: Mapped[str] = mapped_column(Text, nullable=False, default="/import", server_default="/import")
min_width: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
min_height: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
min_width: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
min_height: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
skip_transparent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
transparency_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.9)
skip_transparent: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
transparency_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.9, server_default="0.9")
skip_single_color: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95)
single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30)
skip_single_color: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95, server_default="0.95")
single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30, server_default="30")
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10)
phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10, server_default="10")
# FC-3c downloader knobs
download_rate_limit_seconds: Mapped[float] = mapped_column(
Float, nullable=False, default=3.0
Float, nullable=False, default=3.0,
server_default="3",
)
download_validate_files: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
Boolean, nullable=False, default=True,
server_default="true",
)
# FC-3d scheduling knobs
download_schedule_default_seconds: Mapped[int] = mapped_column(
Integer, nullable=False, default=28800
Integer, nullable=False, default=28800,
server_default="28800",
)
download_event_retention_days: Mapped[int] = mapped_column(
Integer, nullable=False, default=90
Integer, nullable=False, default=90,
server_default="90",
)
download_failure_warning_threshold: Mapped[int] = mapped_column(
Integer, nullable=False, default=5
Integer, nullable=False, default=5,
server_default="5",
)
# FC-3h backup knobs.
backup_db_nightly_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False,
server_default="false",
)
backup_db_nightly_hour_utc: Mapped[int] = mapped_column(
Integer, nullable=False, default=3,
server_default="3",
)
backup_db_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=14,
server_default="14",
)
backup_images_keep_last_n: Mapped[int] = mapped_column(
Integer, nullable=False, default=3,
server_default="3",
)
# FC-6.3 series continuation matcher. enabled gates the rescan; threshold is
# the weighted-score cut-off (0..1) above which a pending suggestion is made.
series_suggest_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True,
server_default="true",
)
series_suggest_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.5,
server_default="0.5",
)
# #830 off-platform file-host downloads — per-host enable lever (default on,
@@ -113,7 +137,9 @@ class ImportSettings(Base):
# English (e.g. "… WIP Part 1") as a European language at ~0.86. CJK stays
# trusted regardless (script-detected). Per-post overrides handle the misses.
translation_min_confidence: Mapped[float] = mapped_column(
Float, nullable=False, default=0.9, server_default="0.9",
# text() because alembic 0084 used sa.text(); see ml_settings for why
# the form matters and why it is per-column (#3275).
Float, nullable=False, default=0.9, server_default=text("0.9"),
)
# Title-based WIP auto-tagging (task #1458). When a freshly-imported post's
+11 -3
View File
@@ -13,10 +13,12 @@ from sqlalchemy import (
Boolean,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -26,6 +28,12 @@ from .base import Base
class ImportTask(Base):
__tablename__ = "import_task"
__table_args__ = (
Index("ix_import_task_created_at_desc", text("created_at DESC")),
# Unindexed FK (#3300).
Index("ix_import_task_result_image_id", "result_image_id"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
batch_id: Mapped[int] = mapped_column(
ForeignKey("import_batch.id", ondelete="CASCADE"), nullable=False, index=True
@@ -33,14 +41,14 @@ class ImportTask(Base):
source_path: Mapped[str] = mapped_column(Text, nullable=False)
task_type: Mapped[str] = mapped_column(String(16), nullable=False) # media|archive
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True)
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending", index=True, server_default="pending")
# Poison-pill circuit breaker (alembic 0026). recovery_count tracks
# how many times the stuck-task sweep has re-queued this row; after
# the cap it's failed with a diagnostic instead of looping. refetched
# bounds the one-shot re-download remediation to a single attempt.
recovery_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
refetched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
recovery_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
refetched: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default="false")
result_image_id: Mapped[int | None] = mapped_column(
ForeignKey("image_record.id", ondelete="SET NULL"), nullable=True
+8 -5
View File
@@ -8,7 +8,7 @@ reads it and routes through cleanup_service.delete_images.
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Integer, String, Text, func
from sqlalchemy import DateTime, Integer, String, Text, func, text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
@@ -23,6 +23,7 @@ class LibraryAuditRun(Base):
params: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", index=True,
server_default="running",
)
# running | ready | applied | cancelled | error
started_at: Mapped[datetime] = mapped_column(
@@ -31,14 +32,16 @@ class LibraryAuditRun(Base):
finished_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
matched_ids: Mapped[list[int]] = mapped_column(JSONB, nullable=False, default=list)
scanned_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
matched_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
matched_ids: Mapped[list[int]] = mapped_column(
JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb")
)
error: Mapped[str | None] = mapped_column(Text, nullable=True)
# Chunked-scan state (alembic 0039): keyset cursor the next chunk resumes
# from, and the last time a chunk made progress (so the recovery sweep can
# tell a progressing multi-chunk audit from a stuck one).
resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
resume_after_id: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
last_progress_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True,
)
+74 -32
View File
@@ -11,6 +11,7 @@ from sqlalchemy import (
String,
func,
select,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
@@ -20,7 +21,10 @@ from .base import Base
class MLSettings(Base):
__tablename__ = "ml_settings"
# Bare name — Base.metadata's naming convention prepends ck_<table>_,
# producing the final ck_ml_settings_singleton (matches migration 0003).
# producing ck_ml_settings_singleton. The chain shipped the DOUBLED
# ck_ml_settings_ck_ml_settings_singleton, because the migration
# pre-prefixed the name and the convention prefixed it again; alembic
# 0088 renames it to what this line has always produced (#3275).
__table_args__ = (CheckConstraint("id = 1", name="singleton"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
@@ -31,17 +35,20 @@ class MLSettings(Base):
# queueing embed work nothing will consume (the daily GPU 'embed' backfill
# covers those images instead).
cpu_embed_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
Boolean, nullable=False, default=True,
server_default="true",
)
# Video embedding (#747). Sample one frame every N seconds (fixed CADENCE, not
# a fixed count) so coverage reflects real screen time regardless of length;
# cap the total so a long video can't explode into hundreds of embeds. The
# per-frame SigLIP embeddings are mean-pooled. Operator-tunable.
video_frame_interval_seconds: Mapped[float] = mapped_column(
Float, nullable=False, default=4.0
Float, nullable=False, default=4.0,
server_default="4",
)
video_max_frames: Mapped[int] = mapped_column(
Integer, nullable=False, default=64
Integer, nullable=False, default=64,
server_default="64",
)
# Tagging-v2 head training (#114). The head is the suggestion source that
# LEARNS from the operator's tags (replacing Camie + centroid). A concept
@@ -49,10 +56,12 @@ class MLSettings(Base):
# head_auto_apply_precision is the precision bar a head must clear (at some
# operating point) to "graduate" into earned auto-apply. Operator-tunable.
head_min_positives: Mapped[int] = mapped_column(
Integer, nullable=False, default=8
Integer, nullable=False, default=8,
server_default="8",
)
head_auto_apply_precision: Mapped[float] = mapped_column(
Float, nullable=False, default=0.97
Float, nullable=False, default=0.97,
server_default="0.97",
)
# Earned auto-apply (#114). A graduated head fires (tags images without a
# human) when this master switch is on AND the head has at least
@@ -61,29 +70,34 @@ class MLSettings(Base):
# default (operator-asked 2026-06-29: opt-OUT, not opt-in); the support +
# measured-precision gates keep it safe, and every auto-tag is reversible.
head_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
Boolean, nullable=False, default=True,
server_default="true",
)
head_auto_apply_min_positives: Mapped[int] = mapped_column(
# Support floor raised 30→50 (operator-asked 2026-07-06): a head needs
# more human labels before it may fire without a human.
Integer, nullable=False, default=50
Integer, nullable=False, default=50,
server_default="30",
)
# CCIP character-match cosine cut (#114). 0.85 default — the v1 flat 0.75
# over-fired (high-reference characters matched a scatter of images); 0.85
# keeps the confident single-character matches. Tunable from the agent card.
ccip_match_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.85
Float, nullable=False, default=0.85,
server_default="0.85",
)
# CCIP auto-apply (#114). Confident matches (>= ccip_auto_apply_threshold,
# above the suggest cut) auto-tag on a daily sweep. ON by default (opt-out);
# single-character references + the high bar keep it safe, every tag reversible.
ccip_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
Boolean, nullable=False, default=True,
server_default="true",
)
ccip_auto_apply_threshold: Mapped[float] = mapped_column(
# Raised 0.92→0.95 (operator-asked 2026-07-06) so only very confident
# character matches auto-tag.
Float, nullable=False, default=0.95
Float, nullable=False, default=0.95,
server_default="0.92",
)
# -- Presentation chrome auto-hide (#141) -------------------------------
# `banner` (chrome — clusters on UI, not content) auto-applies on the sweep
@@ -95,13 +109,21 @@ class MLSettings(Base):
# (opt-out); every auto-tag is reversible. NOTE (#1464): `wip` + `editor
# screenshot` are no longer chrome — they went to the PROCESS path below.
presentation_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
Boolean, nullable=False, default=True,
server_default="true",
)
presentation_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.90
Float, nullable=False, default=0.90,
# text(), not a string, because alembic 0082 used sa.text(): a bare
# string renders DEFAULT '0.90'::double precision while text() renders
# DEFAULT 0.90, and the chain is MIXED — some migrations used one,
# some the other. Same value, different stored expression, so each
# column here mirrors whichever form its own migration used (#3275).
server_default=text("0.90"),
)
presentation_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
Float, nullable=False, default=0.50,
server_default=text("0.50"),
)
# -- Process auto-apply (#1464) ----------------------------------------
# `wip` / `editor screenshot` are PROCESS art — unfinished pieces + program
@@ -115,24 +137,29 @@ class MLSettings(Base):
# (PresentationReview, mode='process') rather than silently marked. OFF by
# default — a new whole-library auto-tagger is opt-in; every auto-tag reversible.
process_auto_apply_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
Boolean, nullable=False, default=False,
server_default="false",
)
process_auto_apply_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.90
Float, nullable=False, default=0.90,
server_default="0.90",
)
process_conflict_threshold: Mapped[float] = mapped_column(
Float, nullable=False, default=0.50
Float, nullable=False, default=0.50,
server_default="0.50",
)
# Default = SigLIP 2 (so400m, 512px) for new installs (migration 0069);
# existing libraries keep their stored value until the operator re-embeds.
embedder_model_version: Mapped[str] = mapped_column(
String(128), nullable=False, default="siglip2-so400m-patch16-512"
String(128), nullable=False, default="siglip2-so400m-patch16-512",
server_default="siglip2-so400m-patch16-512",
)
# The HF model NAME the embedder loads (server CPU embed + announced to the
# GPU agent in the lease). Operator-settable so the embedder is a choice, not
# a hardcode (#1190): set name + version together, then re-embed + retrain.
embedder_model_name: Mapped[str] = mapped_column(
String(128), nullable=False, default="google/siglip2-so400m-patch16-512"
String(128), nullable=False, default="google/siglip2-so400m-patch16-512",
server_default="google/siglip2-so400m-patch16-512",
)
# -- Crop proposers / detectors (#1202, #134) --------------------------
# WHERE-to-crop YOLO detectors feeding the crop→SigLIP bag + CCIP. Config
@@ -145,20 +172,24 @@ class MLSettings(Base):
# person: general COCO figure detector for Western/realistic art the anime
# person-detector misses → NMS-merged with imgutils → CCIP + concept.
detector_person_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
Boolean, nullable=False, default=True,
server_default="true",
)
detector_person_weights: Mapped[str] = mapped_column(
String(512), nullable=False, default="yolo11n.pt"
String(512), nullable=False, default="yolo11n.pt",
server_default="yolo11n.pt",
)
detector_person_conf: Mapped[float] = mapped_column(
Float, nullable=False, default=0.35
Float, nullable=False, default=0.35,
server_default=text("0.35"),
)
# anatomy: booru_yolo anime/furry/NSFW torso components → concept crops.
# Default = yolov11m_aa22 (26 classes, best mAP50-95 0.96), committed in the
# upstream repo so the URL resolves. License UNSTATED — fine for a private
# homelab (operator accepted #1202).
detector_anatomy_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
Boolean, nullable=False, default=True,
server_default="true",
)
detector_anatomy_weights: Mapped[str] = mapped_column(
String(512), nullable=False,
@@ -166,37 +197,47 @@ class MLSettings(Base):
"https://github.com/aperveyev/booru_yolo/raw/main/models/"
"yolov11m_aa22.pt"
),
server_default="https://github.com/aperveyev/booru_yolo/raw/main/models/yolov11m_aa22.pt",
)
detector_anatomy_conf: Mapped[float] = mapped_column(
Float, nullable=False, default=0.30
Float, nullable=False, default=0.30,
server_default=text("0.30"),
)
# panel: comic page → panel regions → concept crops (Apache-2.0, YOLOv12x).
detector_panel_enabled: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True
Boolean, nullable=False, default=True,
server_default="true",
)
detector_panel_weights: Mapped[str] = mapped_column(
String(512), nullable=False,
default="mosesb/best-comic-panel-detection::best.pt",
server_default="mosesb/best-comic-panel-detection::best.pt",
)
detector_panel_conf: Mapped[float] = mapped_column(
Float, nullable=False, default=0.30
Float, nullable=False, default=0.30,
server_default=text("0.30"),
)
# Per-frame caps bound the crop→embed explosion; max_regions is the hard
# per-job backstop; dedupe_iou drops near-duplicate crops before the embed.
detector_max_figures: Mapped[int] = mapped_column(
Integer, nullable=False, default=8
Integer, nullable=False, default=8,
server_default="8",
)
detector_max_components: Mapped[int] = mapped_column(
Integer, nullable=False, default=8
Integer, nullable=False, default=8,
server_default="8",
)
detector_max_panels: Mapped[int] = mapped_column(
Integer, nullable=False, default=8
Integer, nullable=False, default=8,
server_default="8",
)
detector_max_regions: Mapped[int] = mapped_column(
Integer, nullable=False, default=128
Integer, nullable=False, default=128,
server_default="128",
)
detector_dedupe_iou: Mapped[float] = mapped_column(
Float, nullable=False, default=0.85
Float, nullable=False, default=0.85,
server_default=text("0.85"),
)
# -- CCIP character prototypes (#1317) ---------------------------------
# The per-character reference set is precomputed + refreshed INCREMENTALLY
@@ -208,7 +249,8 @@ class MLSettings(Base):
String(128), nullable=True
)
ccip_prototype_cap: Mapped[int] = mapped_column(
Integer, nullable=False, default=64
Integer, nullable=False, default=64,
server_default="64",
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
+1 -1
View File
@@ -35,7 +35,7 @@ class PatreonFailedMedia(Base):
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
first_failed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
+1 -1
View File
@@ -35,7 +35,7 @@ class PixivFailedMedia(Base):
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
first_failed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
+11 -1
View File
@@ -13,11 +13,13 @@ from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
@@ -27,6 +29,10 @@ from .base import Base
class Post(Base):
__tablename__ = "post"
__table_args__ = (
# alembic 0030. The comment above described this index; nothing declared
# it, so autogenerate proposed dropping it (#3275).
Index("uq_post_artist_external_id_null_source", "artist_id", "external_post_id",
unique=True, postgresql_where=text("source_id IS NULL")),
# Source-bound dedup. Postgres treats NULL != NULL so rows
# with source_id IS NULL aren't deduped by this constraint;
# the partial unique index `uq_post_artist_external_id_null_source`
@@ -35,7 +41,11 @@ class Post(Base):
UniqueConstraint("source_id", "external_post_id", name="uq_post_source_external_id"),
CheckConstraint(
"translation_override IN ('auto', 'force', 'original')",
name="ck_post_translation_override",
# Bare name: Base.metadata's naming convention prepends
# ck_<table>_. Pre-prefixing it here doubles the prefix — see
# alembic 0088, which renames the four constraints that shipped
# that way (#3275).
name="translation_override",
),
)
+9 -1
View File
@@ -11,7 +11,7 @@ are pruned by retention.
from datetime import datetime
from sqlalchemy import DateTime, Float, ForeignKey, String, func
from sqlalchemy import DateTime, Float, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -20,6 +20,14 @@ from .base import Base
class PresentationReview(Base):
__tablename__ = "presentation_review"
__table_args__ = (
Index("ix_presentation_review_resolved_at", "resolved_at"),
# Both FKs to tag were unindexed (#3300); tag_id CASCADEs, so a tag
# delete had to scan this table to find its rows.
Index("ix_presentation_review_tag_id", "tag_id"),
Index("ix_presentation_review_conflict_tag_id", "conflict_tag_id"),
)
image_record_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"), primary_key=True
)
+22 -3
View File
@@ -16,7 +16,14 @@ title is the optional chapter name; stated_part is the optional operator-facing
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, Text, func
from sqlalchemy import (
DateTime,
ForeignKey,
Integer,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -25,14 +32,26 @@ from .base import Base
class SeriesChapter(Base):
__tablename__ = "series_chapter"
__table_args__ = (
# alembic 0047 named the UNIQUE `uq_series_chapter_anchor_page`, not
# the `uq_series_chapter_anchor_page_id` a bare `unique=True` would
# render (#3275).
UniqueConstraint("anchor_page_id", name="uq_series_chapter_anchor_page"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
# Both the UNIQUE (above) and the FK carry the names 0047 gave them; the
# convention would render the FK `fk_series_chapter_anchor_page_id_series_page`.
anchor_page_id: Mapped[int] = mapped_column(
ForeignKey("series_page.id", ondelete="CASCADE"),
ForeignKey(
"series_page.id",
ondelete="CASCADE",
name="fk_series_chapter_anchor_page",
),
nullable=False,
unique=True,
)
title: Mapped[str | None] = mapped_column(Text, nullable=True)
stated_part: Mapped[int | None] = mapped_column(Integer, nullable=True)
+17 -2
View File
@@ -14,7 +14,14 @@ number parsed from the source post, nullable when unknown.
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
from sqlalchemy import (
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -23,14 +30,22 @@ from .base import Base
class SeriesPage(Base):
__tablename__ = "series_page"
__table_args__ = (
# alembic 0005 named this `uq_series_page_image`; a bare `unique=True`
# on the column renders `uq_series_page_image_id` under the naming
# convention, which is a different object from the one the database
# has (#3275).
UniqueConstraint("image_id", name="uq_series_page_image"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
series_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
)
# UNIQUE lives in __table_args__ above, under the name 0005 gave it.
image_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"),
nullable=False,
unique=True,
)
# 'placed' = in the series-global run (page_number set); 'pending' = staged
# from a post awaiting the operator's sort (page_number NULL). (#789 P2)
+26 -3
View File
@@ -5,7 +5,16 @@ Multiple sources per artist support creators with cross-platform presence.
from datetime import datetime
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy import (
JSON,
Boolean,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import Base
@@ -14,13 +23,27 @@ from .base import Base
class Source(Base):
__tablename__ = "source"
__table_args__ = (
# alembic 0010. One row per (artist, platform, url): re-adding a source
# the artist already has is an update, not a second row. The model had
# never declared it (#3275), so autogenerate would have proposed
# DROPPING it — the guarantee existed only in the migration chain.
#
# Named explicitly because the naming convention would render this
# `uq_source_artist_id` (uq keys off column_0_name), which is both
# wrong about the shape and not what the database actually has.
UniqueConstraint(
"artist_id", "platform", "url", name="uq_source_artist_platform_url"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
artist_id: Mapped[int] = mapped_column(
ForeignKey("artist.id", ondelete="CASCADE"), nullable=False, index=True
)
platform: Mapped[str] = mapped_column(String(64), nullable=False)
url: Mapped[str] = mapped_column(Text, nullable=False)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
config_overrides: Mapped[dict | None] = mapped_column(JSON, nullable=True)
@@ -32,7 +55,7 @@ class Source(Base):
# by _update_source_health alongside last_error; cleared on 'ok'.
error_type: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True)
check_interval_override: Mapped[int | None] = mapped_column(Integer, nullable=True)
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
consecutive_failures: Mapped[int] = mapped_column(Integer, nullable=False, default=0, server_default="0")
# alembic 0031: sticky deep-scan budget. When > 0, the next N download
# runs use gallery-dl's full-walk config (skip: True + 1800s timeout);
@@ -34,7 +34,7 @@ class SubscribeStarFailedMedia(Base):
ForeignKey("source.id", ondelete="CASCADE"), nullable=False, index=True
)
filehash: Mapped[str] = mapped_column(String(128), nullable=False)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=1, server_default="1")
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
first_failed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
+19 -2
View File
@@ -15,11 +15,13 @@ from sqlalchemy import (
Column,
DateTime,
ForeignKey,
Index,
Integer,
String,
Table,
false,
func,
text,
)
from sqlalchemy import (
Enum as SQLEnum,
@@ -67,17 +69,31 @@ image_tag = Table(
primary_key=True,
),
Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True),
Column("source", String(32), nullable=False, default="manual"),
Column("source", String(32), nullable=False, default="manual", server_default="manual"),
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
# The PK is (image_record_id, tag_id), which leads with the WRONG column
# for the two things that matter most here (#3300): the gallery's tag
# filter (tag_query.py builds `image_tag.c.tag_id == tid`) and the
# ON DELETE CASCADE from tag, which has to find a tag's rows to remove
# them. Without this index both scan the largest table in the schema.
Index("ix_image_tag_tag_id", "tag_id"),
)
class Tag(Base):
__tablename__ = "tag"
__table_args__ = (
# alembic 0002. An EXPRESSION index — COALESCE cannot be expressed as a
# UniqueConstraint, which is why it only ever existed in a migration (#3275).
Index("uq_tag_name_kind_fandom", "name", "kind", text("COALESCE(fandom_id, 0)"),
unique=True),
CheckConstraint(
"(fandom_id IS NULL) OR (kind = 'character')",
name="ck_tag_fandom_requires_character",
# Bare name: Base.metadata's naming convention prepends
# ck_<table>_. Pre-prefixing it here doubles the prefix — see
# alembic 0088, which renames the four constraints that shipped
# that way (#3275).
name="fandom_requires_character",
),
)
@@ -87,6 +103,7 @@ class Tag(Base):
SQLEnum(TagKind, name="tag_kind", values_callable=lambda e: [m.value for m in e]),
nullable=False,
default=TagKind.general,
server_default="general",
)
fandom_id: Mapped[int | None] = mapped_column(
ForeignKey("tag.id", ondelete="SET NULL"), nullable=True, index=True
+9 -2
View File
@@ -5,7 +5,7 @@ in image_prediction stay unmolested.
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy import DateTime, ForeignKey, Index, String, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -14,10 +14,17 @@ from .base import Base
class TagAlias(Base):
__tablename__ = "tag_alias"
__table_args__ = (
# Named explicitly: the database calls this ix_tag_alias_canonical, while
# a bare index=True on the column would generate ix_tag_alias_canonical_tag_id
# and silently propose a drop+create on the next autogenerate (#3275).
Index("ix_tag_alias_canonical", "canonical_tag_id"),
)
alias_string: Mapped[str] = mapped_column(String(255), primary_key=True)
alias_category: Mapped[str] = mapped_column(String(32), primary_key=True)
canonical_tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False, index=True
ForeignKey("tag.id", ondelete="CASCADE"), nullable=False
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
+16 -3
View File
@@ -5,7 +5,7 @@ Prevents re-suggestion AND prevents allowlist auto-apply on that image.
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, func
from sqlalchemy import DateTime, ForeignKey, Index, func
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -14,11 +14,24 @@ from .base import Base
class TagSuggestionRejection(Base):
__tablename__ = "tag_suggestion_rejection"
__table_args__ = (
# Named explicitly; see tag_alias for why (#3275).
Index("ix_tag_suggestion_rejection_tag", "tag_id"),
)
# Both FKs named explicitly. alembic 0003 used a hand-shortened `tsr`
# prefix; the convention would render the full table name (#3275).
image_record_id: Mapped[int] = mapped_column(
ForeignKey("image_record.id", ondelete="CASCADE"), primary_key=True
ForeignKey(
"image_record.id",
ondelete="CASCADE",
name="fk_tsr_image_record_id_image_record",
),
primary_key=True,
)
tag_id: Mapped[int] = mapped_column(
ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True, index=True
ForeignKey("tag.id", ondelete="CASCADE", name="fk_tsr_tag_id_tag"),
primary_key=True,
)
rejected_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
+15 -4
View File
@@ -15,7 +15,7 @@ backend.app.tasks.maintenance.recover_stalled_task_runs (Beat 5 min).
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
from sqlalchemy import DateTime, Index, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
@@ -24,12 +24,21 @@ from .base import Base
class TaskRun(Base):
__tablename__ = "task_run"
__table_args__ = (
# alembic 0016: the three task-history indexes (#3275).
Index("ix_task_run_name_started", "task_name", text("started_at DESC")),
Index("ix_task_run_queue_started", "queue", text("started_at DESC")),
Index("ix_task_run_status_started", "status", text("started_at DESC")),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
celery_task_id: Mapped[str] = mapped_column(
String(64), nullable=False, index=True,
)
queue: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
task_name: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
# Neither carries index=True: ix_task_run_queue_started and
# ix_task_run_name_started already lead with these columns (#3301).
queue: Mapped[str] = mapped_column(String(32), nullable=False)
task_name: Mapped[str] = mapped_column(String(128), nullable=False)
target_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, index=True,
@@ -39,7 +48,9 @@ class TaskRun(Base):
)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="running", index=True,
# No index=True — ix_task_run_status_started leads with `status`.
String(16), nullable=False, default="running",
server_default="running",
)
error_type: Mapped[str | None] = mapped_column(String(128), nullable=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
+174 -12
View File
@@ -9,14 +9,17 @@ git.fabledsword.com/bvandeusen/ci-python:3.14
## Image deps used
- python 3.14
- ruff (analyzer for `backend/`, `tests/`, `alembic/`)
- ruff (analyzer for `backend/`, `tests/`, `alembic/`, `agent/`, `scripts/`)
- node (frontend job: `npm install` + vitest + vite build)
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml — Fabled-Git registry push)
- docker CLI + buildx (`.forgejo/workflows/build.yml`: build-web, build-ml, build-agent — Fabled-Git registry push, and `imagetools inspect`/`create` for the reuse path)
## Secondary runtime image
node:24-bookworm-slim — `.forgejo/workflows/extension.yml` only.
`.forgejo/workflows/release.yml` runs on `ci-python:3.14` like everything else
and installs nothing: it needs git and stdlib python, and builds no image.
The extension lane is the one job that does NOT run on `ci-python:3.14`: it
needs a current Node for `web-ext` and vitest and nothing Python at all. Kept
on the upstream slim image rather than adding a Node toolchain to `ci-python`,
@@ -55,16 +58,175 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
modules, so the specs exercise exactly the bytes packaged into the XPI.
- **`extension/scripts/packaging.sh` is the single definition of what ships
inside the XPI.** Three consumers read from it rather than keeping their own
copy: web-ext's `--ignore-files` (`extension/package.json`), the `:(exclude)`
pathspec in `ci.yml`'s `extension-version` guard, and the `git log` pathspec
that derives the extension version. Three hand-kept copies of that one fact
is what allowed issue #2397.
- Jobs that derive the extension version check out with `fetch-depth: 0`. The
version is the commit TIME of the newest packaged-extension change (minutes
since 2020-01-01, per family rule 149 — never a commit count, which orders
by branch rather than by recency). A depth-1 clone sees one commit and
derives a wrong, too-low value rather than failing, so the full-history
checkout is load-bearing wherever `packaging.sh version` is called.
copy: web-ext's `--ignore-files` (`extension/package.json`), the `git log`
pathspec inside the script's own version derivation, and `scripts/artifacts.sh`,
which appends the extension's set to web's because the web image bundles the
signed XPI. Hand-kept copies of that one fact is what allowed issue #2397, so
`extension/test/version.spec.js` asserts no workflow has reintroduced a
literal `:(exclude)extension/…`.
- **Packaged and version-relevant are two different sets** (#3156). `scripts/`
is excluded from the XPI and is NOT excluded from the version derivation,
because `packaging.sh` decides the version string stamped into the packaged
`manifest.json`. The membership test is *"can changing this file change the
published bytes?"*, not *"is this file copied in?"* — which is why the script
keeps two lists rather than one.
- **The shipped extension version is derived, not committed.** It is the commit
TIME of the newest packaged-extension change, rendered `YYYY.M.D.HHMM` UTC
(family rules 148/149 — never a commit count, which orders by branch rather
than by recency). `build.yml`'s `sign-extension` computes it and stamps it
into `extension/manifest.json` + `package.json` in the working tree before
signing; the stamp is never committed. The version in the repo is **wholly
inert** — since milestone 318 step 8 there is no hand-set MAJOR.MINOR either.
- **The extension is the one artifact that does not zero-pad, and that is not a
drift** (#3138). Mozilla's grammar for AMO is
`^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$` — a segment is the single
digit `0` or starts 1-9, and there are at most four. `2026.08.29.0201` is
rejected; `2026.8.29.201` is the same value one character narrower per
segment, and rule 148 defines comparison as numeric per segment, so nothing is
reordered. `ci.yml`'s `extension-version` lane asserts the derived string
against that exact regex, plus a `YYYY.M.D.HHMM` shape check that would catch
a regression to the pre-318 `1.0.<minutes>` — which AMO would accept and which
orders below everything already signed. Checking here is the whole point: AMO
409s on re-signing, so a version it rejects is burned and cannot be reused.
`scripts/artifacts.sh version extension` **delegates** to `packaging.sh` so
the two cannot answer differently.
- Every job that derives anything checks out with `fetch-depth: 0` — all four
`build.yml` jobs, `ci.yml`'s `extension-version` and `backend-lint-and-test`
(for `tests/test_artifact_paths.py` and `test_artifact_identity.py`), and
`release.yml`, which additionally walks the tag graph. A depth-1 clone sees
one commit and derives a wrong, too-low value **rather than failing**, so the
full-history checkout is load-bearing rather than incidental.
- **`scripts/artifacts.sh` is the same shape one level up: one definition per
artifact of what it is built from, and the two values derived from it.**
`revision` (12 hex of the newest commit touching that set) and `version`
(`YYYY.MM.DD.HHMM` UTC, rule 148). Four artifacts, four independent answers,
so a push touching only `agent/` leaves web and ml alone.
`tests/test_artifact_paths.py` reads each Dockerfile and asserts every COPY
source is covered, so adding a COPY without updating the script fails CI.
- **A file that DECIDES an artifact's identity belongs in its set even though it
is copied into nothing** — `packaging.sh` for the extension and web (#3156),
and `artifacts.sh` itself for web (#3202), which decides the `FC_VERSION`
baked into that image. Only web needs the second entry: every artifact stamps
a revision, but a revision has a backstop (a changed derivation stops matching
the published label and forces a rebuild) and a version has none, because
nothing compares it to anything. `tests/test_artifact_paths.py`'s `DERIVERS`
table is the guard.
- **Builds are skipped when the content is already published.** Each image
carries its revision as an `fc.revision` LABEL, and `build.yml` reads that
label back off the moving channel tag (`imagetools inspect --format`). Equal
to the derived revision means the bytes are already published, so the job
repoints the remaining tags at the existing manifest instead of rebuilding.
Two things this depends on: an inspect that errors for ANY reason reads as a
MISS so no needed build is ever skipped, and the repoint must EXCLUDE the
source tag — `imagetools create` wraps its source in a manifest index, and
config labels do not resolve through an index, so writing the channel tag
from itself destroys the label the next run reads (#3183).
- **The build pushes exactly ONE tag — the channel's — and every other tag is
written registry-side afterwards** (#3190). buildx on this runner pushes the
first tag to the registry and then re-pushes the rest through the docker
driver, out of a local image store that a registry-direct build never fills;
it fails intermittently with `tag does not exist`. On `dev` that only reddens
a job, but on `main` it silently skips `:c-<sha>` while `:latest` publishes
fine — a missing rollback tag has no consumer that fails, so nothing but the
red job would notice until somebody needs to roll back. `imagetools create`
has no local store to be absent from, and it is the code the reuse path
already ran, so both paths now share one proven route. The cost: `:c-<sha>`
is an index rather than a plain image, so `fc.revision` does not resolve
through it — nothing reads it there, and the index names the same manifest.
- **The image builds run on a `docker-container` buildx builder, and
`provenance`/`sbom` are explicitly OFF** (milestone 326 step 1). The builder
is what makes a registry layer cache possible at all — the default `docker`
driver cannot export one (#3114) — and it is #3190's leading suspect, since
it is the driver that resolves image metadata against a local store a
registry-direct push never fills. **The attestation flags are load-bearing,
not tidiness:** on the container driver `build-push-action@v5` defaults
`provenance` to true when pushing, an attestation manifest makes the pushed
tag a manifest INDEX, and config labels do not resolve through an index — so
leaving them on would make every push read `fc.revision=<none>`, miss, and
rebuild forever with every lane green. Same failure as #3183, different door.
These jobs run inside a container against a mounted docker socket, so the
buildkit container is a sibling rather than a child.
- **All three images import and export a registry layer cache**
(`<image>:buildcache`, `mode=max`). This is not an optimisation bolted onto
the driver change — it is the other half of it. The `docker-container`
driver gets a fresh buildkit instance per job and therefore has **no local
layer store at all**, where the old `docker` driver at least reused whatever
the runner's dockerd happened to hold. Measured on run 4896, the first builds
after the driver moved: web 3m44s (was 2m23s), ml 3m49s (was 3m20s), agent
11m12s (was 9m26s) — every one slower. A `:buildcache` tag is read by every
build that runs, is one moving ref per image, holds cache blobs rather than a
shippable artifact, and is overwritten in place, so it is not a return of the
per-version tags milestone 318 withdrew (#3114).
- **`build.yml` accepts a `workflow_dispatch` with `force_build`**, which
bypasses the reuse check for all three images. It exists because
skip-if-exists makes its own build path untestable: `agent/` has not changed
since 2026-07-17, so the agent build has not run in six weeks and cannot be
exercised on demand — and #3190 lives on exactly that path. Editing
`build.yml` does not force a build either, deliberately: the workflow is not
shipped bytes and is in no artifact's path set. The flag is read through
`github.event.inputs` into an env var rather than interpolated into a run
block, and it is checked inside the reuse step so that one decision drives
both the build and the repoint.
- **A weekly `schedule` rebuilds all three images against fresh base layers**
(Sunday 06:00 UTC, milestone 326 step 4, #3154). Skip-if-exists is keyed on
OUR source, so an artifact whose source stops moving stops picking up base
updates — `agent/` has not changed since 2026-07-17 and would otherwise serve
that day's `nvidia/cuda` layers forever. Four things make it work:
- It **builds `main`, not the branch that triggered it.** Forgejo registers a
cron from the DEFAULT branch (`dev` here), so a scheduled run arrives with
`github.ref` on dev. The ref is decided once in a top-level `env:
BUILD_REF` that every checkout in the file takes, rather than per job —
otherwise `sign-extension` would derive dev's extension version while
`build-web` bundled main's, and the release download would 404 on a version
that exists perfectly well. Every job then ASSERTS its checkout is `main`
before doing anything, because `env` inside `with:` is not a context this
runner is known to evaluate — if it silently resolved to empty, checkout
would fall back to the triggering ref and the refresh would publish dev's
source to `:latest` with every lane green.
- It **publishes only `:latest`.** `:c-<sha>` for main's HEAD already names
the bytes that commit built; re-pushing it over refreshed layers would
break the one tag rule 145 makes immutable, and it is the rollback unit.
The repoint step needs no schedule case for this — the tag list is the
channel tag alone, so SOURCE is the only entry, it is excluded as always,
and the step correctly does nothing.
- **`:latest` and `:c-<sha>` therefore diverge between a refresh and the next
`main` push, by design.** They re-converge on that push: it hits reuse (a
refresh does not move `fc.revision`, because it does not touch the source),
and the repoint writes the NEW `:c-<sha>` from the refreshed `:latest`. The
push path needed no change for this, because the repoint already excluded
the source tag — the same rule that keeps the label readable also keeps a
refresh from being undone.
- **`pull: true` on the scheduled path only** is the mechanism: a moved base
tag changes the `FROM` layer's cache key and everything above it rebuilds.
**It does not currently make the unmoved case free.** Measured on the first
real fire (run 4934, 2026-08-30): every content step reported `CACHED` and
the bases resolved to unchanged digests, yet all three `:latest` tags got a
NEW manifest digest, because buildkit mints a fresh image config per run and
republishes identical layers under it. So `:latest` is rewritten weekly
whether or not anything changed, and `:c-<sha>` is handed a new manifest to
diverge from on the same cadence — a digest change stops meaning anything.
Tracked as #3265; the likely fix is a deterministic `SOURCE_DATE_EPOCH`.
Separately not caught: a Debian package update inside the `apt-get install`
layer while the base tag stands still — a lag rather than a hole, since the
official python/cuda images rebuild with those updates baked in.
- **`FC_CHANNEL` and `FC_VERSION` are build args, not runtime settings.**
`build.yml` passes them to the web image only — the ml and agent images have
nothing to report them to. `/api/health` returns both, the foot of Settings
renders them, and `/api/extension/manifest` reports the channel beside the
extension version so an install can be traced to a channel. With no version image tags, that
self-report is the ONLY answer to "which build is this?" — which is why a
missing version renders `unknown` rather than a blank: an empty footer reads
as "no version", a different and false claim.
Both are declared LAST in the Dockerfile on purpose: an ARG invalidates every
layer below it, and these are the values that differ between the dev and main
builds of identical source, so placing them earlier would stop the two
channels ever sharing a cached `pip install`. Empty by default — a local build
then reports nothing rather than claiming a channel it is not on.
- **The channel is never folded into the version.** A `-dev` suffix makes the
extension's per-segment `parseInt` comparator read that segment as 0, so every
dev build compares equal to every other — issue #2993 exactly (rule 149).
`frontend/test/systemBuild.spec.js` pins the rendered version to the bare
number.
- Callers MUST `set -f` before substituting the script's output. Without it the
shell expands `test/**` against the working tree and silently narrows the
pattern to whatever files exist at that moment — a failure that looks like
+23 -5
View File
@@ -74,7 +74,25 @@ services:
retries: 5
web:
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
# :latest, NOT :dev — this file IS the install path.
#
# `docker compose up -d` merges docker-compose.override.yml, which sets
# build: for all five app services, and a build: wins over image:. So a
# contributor never pulls this tag and is unaffected by what it says.
#
# The tag is consulted only on `docker compose -f docker-compose.yml up -d`
# — the documented production path, which skips the override. That is a
# stranger installing the product, and they must land on the stable channel.
#
# :latest is main, which IS production (rule 147). :dev is the rolling
# bleeding-edge channel we work out of, republished several times a day with
# no stability promise. This file pinned :dev on all five services until
# 2026-08-31 (#3270), so the documented install shipped development builds.
# It went unnoticed because nobody who works on the project takes this path:
# the operator deploys from a swarm stack file, contributors get the
# override. Do not "fix" this back to :dev while debugging — use the
# override, or -f with an explicit tag on the command line.
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
command: ["web"]
# Graceful shutdown: give the container time to drain in-flight work on a
# deploy (docker SIGTERMs, then SIGKILLs after this window — default is only
@@ -122,7 +140,7 @@ services:
redis: { condition: service_healthy }
worker:
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
command: ["worker"]
# Drain in-flight import/thumbnail/download tasks before SIGKILL on deploy.
stop_grace_period: 90s
@@ -142,7 +160,7 @@ services:
redis: { condition: service_healthy }
scheduler:
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
command: ["scheduler"]
# Quick maintenance/scan lane + beat — short tasks, modest drain window.
stop_grace_period: 60s
@@ -163,7 +181,7 @@ services:
# 30-min backup or a multi-chunk audit can never starve the 5-min recovery
# sweeps / vacuum (operator-flagged 2026-06-07). One slot — these are heavy.
maintenance-long:
image: git.fabledsword.com/bvandeusen/fabledcurator:dev
image: git.fabledsword.com/bvandeusen/fabledcurator:latest
command: ["worker"]
# Longest lane (DB backups, library audits, translation backfill) — give it
# the most room to finish a chunk gracefully. Chunked + idempotent, so a job
@@ -184,7 +202,7 @@ services:
redis: { condition: service_healthy }
ml-worker:
image: git.fabledsword.com/bvandeusen/fabledcurator-ml:dev
image: git.fabledsword.com/bvandeusen/fabledcurator-ml:latest
command: ["ml-worker"]
# A single GPU inference pass can run tens of seconds — let it finish.
stop_grace_period: 120s
+74 -6
View File
@@ -7,7 +7,8 @@ page in one click.
## Install (operator)
The signed XPI is bundled into the FC Docker image. Open FC →
The signed XPI is bundled into the FC Docker image`:dev` and
`:latest` each carry their own channel's build. Open FC →
Settings → Maintenance → Browser extension → click "Install Firefox
extension". Firefox shows its native install prompt. After installing,
open the extension's options page (about:addons → FabledCurator →
@@ -20,6 +21,7 @@ same card.
cd extension/
npm install --no-save # web-ext only
npm run lint # web-ext lint
npm run test:unit # vitest — lib/ logic + packaging/version checks
npm run start # launches Firefox with extension loaded
npm run build # unsigned XPI in web-ext-artifacts/
```
@@ -36,10 +38,76 @@ npm run build # unsigned XPI in web-ext-artifacts/
- [ ] Subscriptions list: popup → "Sources" tab → list renders
- [ ] Check now: click play icon on source row → no error toast
## Versioning — the committed number decides nothing
The shipped version is **derived**, not committed. `scripts/packaging.sh
version` returns `YYYY.M.D.HHMM` in UTC: the commit *time* of the newest change
to a packaged extension file. `build.yml` computes it and stamps it into both
`manifest.json` and `package.json` at build time. The stamp is never
committed — the commit carrying it would itself be a change to the extension,
which would move the version again.
So:
- **Editing the version does nothing.** All of it is overwritten before web-ext
ever reads it. There is no bump to make, and none to forget. There is no
hand-set part left either: MAJOR.MINOR went away with milestone 318 step 8.
- `npm run build` locally produces an XPI labelled with the *committed*
version, since nothing stamped it. Fine for loading into a test profile; not
what ships.
**Why the extension is the one artifact that does not zero-pad.** Every other
FC artifact emits rule 148's `YYYY.MM.DD.HHMM`. AMO will not take it: Mozilla's
grammar for addons.mozilla.org is
```
^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
```
— each segment is the single digit `0` or starts 1-9, so `08` and `0201` are
rejected, and at most four segments are allowed. The extension therefore emits
**the same numbers unpadded**: `2026.8.29.201` where the rest of the family
says `2026.08.29.0201`. Rule 148 already defines comparison as numeric per
segment, under which the two are equal, so nothing is reordered by the choice
and left-padding each segment recovers the family string exactly. `ci.yml`'s
`extension-version` lane checks the derived string against that regex on every
push — the cheap place to find out, because AMO 409s on re-signing and a
rejected version is burned for good.
Why commit time and not a commit count: a count is per-branch, so `dev` and
`main` count different histories of the same code and their versions end up
ordered by which branch accumulated more commits rather than by which is newer.
Commit time gives both branches the same number for the same source — which is
exactly what lets one AMO signature serve both channels (family rule 149, FC
issue #3092).
## Channels
`dev` and `main` each build and sign their own extension, and an install is
tied to whichever FC instance it points at — Firefox's static `update_url`
cannot apply here, since every FC install is a different host, so the extension
asks its configured backend. **The channel therefore IS the instance.**
Switching channel means repointing the FC URL in options and reinstalling from
that host; there is no separate channel setting, and adding one would
contradict each server build shipping its own extension.
The channel is reported *beside* the version, never inside it:
`/api/extension/manifest` answers `{"version": "...", "channel": "dev"}`. It is
optional — an instance that declares none simply omits the key, and the popup,
the toolbar tooltip and the Settings card all read exactly as they did before
the field existed. Do not be tempted to make it a `-dev` version suffix: the
comparator parses each dotted segment with `parseInt`, so a suffixed segment
reads as 0 and every dev build compares equal to every other, collapsing "no
update available" and "I cannot read this version" into one answer.
## Release
Bump `manifest.json` + `package.json` SemVer (both files) and commit
under `extension/**`. The `.forgejo/workflows/extension.yml` workflow
runs `web-ext sign` on main, commits the signed XPI to
`frontend/public/extension/`, and the next FC server build bundles it
into the Docker image.
Nothing to do by hand. Push to `dev`: `build.yml` signs the extension if this
change moved the version, caches the signed XPI as a Forgejo `ext-<version>`
release, and bundles it into `fabledcurator:dev`. Merging to `main` derives the
same version, hits that cache, and bundles the byte-identical XPI into
`:latest` with no second AMO call.
AMO refuses to re-sign a version it has already issued, so signing is one-shot
per version — which is why the cache exists and why the version must never move
backwards.
+25 -2
View File
@@ -37,7 +37,16 @@ ensureInitialized().catch(e => console.error('init failed:', e));
// configured backend for the latest published version and nudge the operator to
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
// a toolbar badge (daily). /api/extension/manifest is public and returns
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
// {version, latest_url, sha256} plus an OPTIONAL {channel} naming which channel
// that instance serves ("dev"/"main", #3113); the XPI is served from the web
// root (not /api).
//
// The channel IS the instance: Firefox's static update_url cannot apply here
// because every FC install is a different host, so the extension asks its
// configured backend — which means switching channel is repointing apiUrl in
// options and reinstalling from that host. There is no separate channel
// setting to build, and building one would contradict each server build
// shipping its own extension.
function versionIsNewer(candidate, current) {
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
@@ -60,12 +69,22 @@ async function checkForUpdateInfo() {
}
const currentVersion = browser.runtime.getManifest().version;
const latestVersion = info && info.version ? info.version : null;
// Which channel the configured instance serves — reported ALONGSIDE the
// version, never folded into it. A `-dev` suffix would have to survive
// versionIsNewer's parseInt above, and it wouldn't: the segment would read
// as 0 and every dev build would compare equal to every other.
//
// null is a normal answer, not a failure — an instance built before the
// field existed, or one built locally with no channel declared. Nothing
// below branches on it except the label.
const channel = info && info.channel ? info.channel : null;
// latest_url is served from the web root, not the JSON API.
const base = api.webRoot();
return {
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
currentVersion,
latestVersion,
channel,
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
};
}
@@ -77,7 +96,11 @@ async function refreshUpdateBadge() {
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
if (r.updateAvailable) {
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
// Channel first, version second, and the channel dropped entirely when
// the instance doesn't report one — so the tooltip reads exactly as it
// did before the field existed rather than saying "(unknown ...)".
const label = r.channel ? `${r.channel} v${r.latestVersion}` : `v${r.latestVersion}`;
await browser.action.setTitle({ title: `FabledCurator — update available (${label})` });
} else {
await browser.action.setTitle({ title: 'FabledCurator' });
}
+5 -1
View File
@@ -81,8 +81,12 @@ async function checkForUpdate() {
}
function showUpdateBanner(r) {
// The channel names itself beside the version, never inside it (#3113).
// Absent when the instance doesn't report one, and the banner then reads
// exactly as it did before the field existed.
const channel = r.channel ? ` (${r.channel})` : '';
document.getElementById('update-text').textContent =
`Update available — v${r.latestVersion} (installed v${r.currentVersion})`;
`Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`;
// Opening the signed XPI triggers Firefox's native install prompt.
document.getElementById('update-btn').addEventListener('click', () => {
browser.tabs.create({ url: r.xpiUrl });
+92 -45
View File
@@ -6,7 +6,9 @@
# keeping three copies of one fact in sync by hand is how issue #2397 happened:
#
# 1. web-ext's --ignore-files (extension/package.json's four scripts)
# 2. the :(exclude) pathspec (ci.yml's extension-version guard)
# 2. the :(exclude) pathspec (what moves the version — a WIDER
# set than the ignore list; see
# NOT_VERSION_RELEVANT)
# 3. the git-log pathspec (the derived version, below)
#
# They now all read from here. POSIX sh only — CI's run shell is busybox.
@@ -35,8 +37,30 @@ set -euf
NOT_PACKAGED_TRACKED='package.json package-lock.json README.md .gitignore vitest.config.js scripts scripts/** test test/**'
NOT_PACKAGED_BUILD='web-ext-artifacts node_modules'
# Paths under extension/ that cannot change the SHIPPED BYTES, and so must not
# move the derived version.
#
# Deliberately NOT the same list as NOT_PACKAGED_TRACKED, and the whole
# difference is `scripts/`. packaging.sh is not packaged into the XPI — but it
# DECIDES the version string, and build.yml stamps that string into the
# manifest.json that is packaged. A change to how the version is computed is
# therefore a change to the shipped bytes.
#
# Excluding it was harmless only while every push rebuilt the web image.
# Milestone 313 step 4 made the rebuild conditional on the derived revision
# moving, which turned it into a silent failure: a packaging.sh change gives a
# NEW version, so sign-extension misses its ext-<version> cache and signs —
# while build-web sees an unmoved revision, reuses the published image, and
# ships the OLD XPI. An orphaned AMO signature, and an instance quietly serving
# code the registry says is current.
#
# The two directions are not symmetric, which is why this list is the narrower
# one. Too wide costs a re-sign and a rebuild for a change that ships nothing
# new. Too narrow serves stale bytes and says nothing.
NOT_VERSION_RELEVANT='package.json package-lock.json README.md .gitignore vitest.config.js test test/**'
usage() {
echo "usage: packaging.sh {ignore|pathspec|version|major-minor|patch}" >&2
echo "usage: packaging.sh {ignore|pathspec|version}" >&2
exit 2
}
@@ -49,46 +73,61 @@ cmd_ignore() {
echo "$NOT_PACKAGED_TRACKED $NOT_PACKAGED_BUILD"
}
# git pathspec excluding the non-packaged tracked files, e.g.
# :(exclude)extension/package.json :(exclude)extension/test/**
# git pathspec excluding the tracked files that cannot change the shipped
# bytes, e.g. :(exclude)extension/package.json :(exclude)extension/test/**
#
# This answers "what moves the version?", NOT "what goes in the XPI?" — see
# NOT_VERSION_RELEVANT for why those differ. cmd_ignore answers the other one.
# Same `set -f` requirement as above.
cmd_pathspec() {
for entry in $NOT_PACKAGED_TRACKED; do
for entry in $NOT_VERSION_RELEVANT; do
printf ':(exclude)extension/%s ' "$entry"
done
echo
}
# MAJOR.MINOR stays hand-set in manifest.json — it's the part that carries
# deliberate meaning. Only the patch component is derived.
cmd_major_minor() {
root=$(git rev-parse --show-toplevel)
grep -E '"version"' "$root/extension/manifest.json" \
| head -1 \
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+)\.([0-9]+).*/\1.\2/'
# Strip leading zeros from one segment, leaving at least one digit.
#
# This exists for AMO and nothing else. Mozilla's version grammar for
# addons.mozilla.org is documented as
#
# ^(0|[1-9][0-9]{0,8})([.](0|[1-9][0-9]{0,8})){0,3}$
#
# — each segment is either the single digit `0` or starts 1-9, so `08` and
# `0201` are rejected outright, while `0` itself is fine. MDN states it in
# prose too: "Non-zero numbers must not include a leading zero."
#
# POSIX sh has no trim-loop, hence the while.
unpad() {
s=$1
while [ "${#s}" -gt 1 ]; do
case "$s" in
0*) s=${s#0} ;;
*) break ;;
esac
done
printf '%s' "$s"
}
# 2020-01-01T00:00:00Z — the anchor for the derived patch component. Fixed
# forever; moving it would renumber every version downwards.
VERSION_EPOCH=1577836800
# Minutes since VERSION_EPOCH of the LATEST commit that touched a PACKAGED
# extension file.
# The extension's version: `YYYY.M.D.HHMM`, UTC, derived from the commit TIME
# of the newest change to a PACKAGED extension file.
#
# Time-derived, per family rule 149: an artifact's ordering key must never be a
# commit count. A count is per-branch — `dev` and `main` count different
# histories of the same code — so the moment BOTH channels publish, their
# versions order by which branch accumulated more commits rather than by which
# is newer. A squash-merge makes that permanent: main gains one commit where dev
# gained five, so dev climbs away from main and a dev install can never cross
# back. That is Roundtable's 2026-08-24 incident (`versionCode` was the branch's
# commit count) in a different repo. Measured here on 2026-08-27: main=23,
# dev=24 under the old formula — one apart, which is exactly how the inversion
# stays invisible until it strands somebody.
# THE ONE DELIBERATE DEPARTURE FROM THE FAMILY SHAPE, and it is a rendering
# difference only. Rule 148 says `YYYY.MM.DD.HHMM` zero-padded, and every other
# FC artifact emits exactly that. AMO's grammar (see unpad) forbids the padding,
# and AMO is not negotiable: a rejected version is burned, since AMO 409s on
# re-signing a version it has already seen. So the extension emits THE SAME
# NUMBERS unpadded — 2026.08.29.0201 and 2026.8.29.201 are one value in two
# renderings, and rule 148 already specifies comparison as numeric per segment,
# under which they are equal. Nothing published is reordered by the choice, and
# left-padding each segment recovers the family string exactly.
#
# HHMM is one segment, not two, because AMO allows at most FOUR. Unpadded that
# reads oddly (00:14 -> `14`, midnight -> `0`) but stays strictly increasing
# within a day, which is all the ordering needs.
#
# Why the commit's time and not the build's:
# * MONOTONIC — max() over a set that only ever gains members. Verified
# across all 24 extension-touching commits: zero non-monotonic steps.
# * MONOTONIC — max() over a set that only ever gains members.
# * STABLE while the extension is unchanged, so an unchanged extension keeps
# its version, the ext-<version> signature cache still hits, and AMO is
# called once per extension CHANGE rather than once per push. Build-time
@@ -99,32 +138,40 @@ VERSION_EPOCH=1577836800
# produced for byte-identical code. Same code, same version, one signing.
# * REPRODUCIBLE — any checkout of a commit yields that commit's version.
#
# Never a commit count (family rule 149): a count is per-branch, so `dev` and
# `main` count different histories of the same code and order by which branch
# accumulated more commits rather than by which is newer. A squash-merge makes
# that permanent. Roundtable's 2026-08-24 incident, in a different repo.
#
# Requires real history: a depth-1 clone sees one commit and will derive a wrong
# (too low) value. Every consumer must check out with fetch-depth: 0.
cmd_patch() {
#
# Formatted through git rather than date(1): busybox date does not reliably
# accept `-d @<epoch>`, and git's --date=format-local is available wherever git
# is. TZ=UTC so the value does not depend on the runner's timezone.
cmd_version() {
root=$(git rev-parse --show-toplevel)
# Unquoted on purpose: the pathspec must word-split into separate args.
# Globbing is already off script-wide (set -euf above).
# shellcheck disable=SC2046
ts=$(cd "$root" && git log --format=%ct HEAD -- extension/ $(cmd_pathspec) \
| sort -n | tail -1)
if [ -z "$ts" ]; then
sha=$(cd "$root" && git log --format='%ct %H' HEAD -- extension/ $(cmd_pathspec) \
| sort -n | tail -1 | cut -d' ' -f2)
if [ -z "$sha" ]; then
echo "packaging.sh: no commit touches a packaged extension file" >&2
exit 1
fi
echo $(( (ts - VERSION_EPOCH) / 60 ))
}
cmd_version() {
echo "$(cmd_major_minor).$(cmd_patch)"
padded=$(cd "$root" && TZ=UTC git show -s --format=%cd \
--date='format-local:%Y.%m.%d.%H%M' "$sha")
# Rebinding the function's own positional params, which are unused here.
# shellcheck disable=SC2046
set -- $(echo "$padded" | tr '.' ' ')
echo "$(unpad "$1").$(unpad "$2").$(unpad "$3").$(unpad "$4")"
}
[ $# -ge 1 ] || usage
case "$1" in
ignore) cmd_ignore ;;
pathspec) cmd_pathspec ;;
version) cmd_version ;;
major-minor) cmd_major_minor ;;
patch) cmd_patch ;;
*) usage ;;
ignore) cmd_ignore ;;
pathspec) cmd_pathspec ;;
version) cmd_version ;;
*) usage ;;
esac
+95 -16
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { readdirSync, readFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
@@ -8,10 +8,10 @@ const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..')
const read = (name) => JSON.parse(readFileSync(path.join(EXT_DIR, name), 'utf8'))
const readText = (...seg) => readFileSync(path.join(EXT_DIR, ...seg), 'utf8')
// Only the git-free subcommands are exercised here: `version`/`patch` shell out
// to git, and the extension lane runs on node:24-bookworm-slim which may not
// ship it. Those two are covered where git is guaranteed — ci.yml and build.yml
// run on ci-python.
// Only the git-free subcommands are exercised here: `version` shells out to
// git, and the extension lane runs on node:24-bookworm-slim which may not ship
// it. That one is covered where git is guaranteed — ci.yml's extension-version
// lane and build.yml both run on ci-python.
const packaging = (cmd) =>
execFileSync('sh', [path.join(EXT_DIR, 'scripts', 'packaging.sh'), cmd], {
cwd: EXT_DIR,
@@ -44,9 +44,39 @@ describe('packaging.sh — the single definition of what ships', () => {
// covering anything added later.
const pathspec = packaging('pathspec')
expect(pathspec).toContain(':(exclude)extension/test/**')
expect(pathspec).toContain(':(exclude)extension/scripts/**')
expect(pathspec.some((e) => e.includes('.spec.js'))).toBe(false)
expect(pathspec.some((e) => e.includes('helpers'))).toBe(false)
const ignore = packaging('ignore')
expect(ignore).toContain('test/**')
expect(ignore).toContain('scripts/**')
expect(ignore.some((e) => e.includes('.spec.js'))).toBe(false)
})
it('lets packaging.sh move the version, though it never ships in the XPI', () => {
// The two lists answer different questions and this is the one place they
// disagree. scripts/ is ignored by web-ext — it is repo tooling, not addon
// code — but packaging.sh DECIDES the version string, and build.yml stamps
// that string into the manifest.json that does ship. So changing how the
// version is computed changes the shipped bytes.
//
// Excluding it from the pathspec was invisible while every push rebuilt the
// web image. Milestone 313 step 4 made that rebuild conditional on the
// derived revision moving, and the omission turned into a silent failure:
// a new version means sign-extension misses its ext-<version> cache and
// signs, while build-web sees an unmoved revision, reuses the published
// image and ships the OLD XPI. An orphaned signature, and an instance
// serving code the registry calls current.
const pathspec = packaging('pathspec')
expect(
pathspec.some((e) => e.startsWith(':(exclude)extension/scripts')),
'the pathspec excludes scripts/, so a change to how the version is '
+ 'derived would not move the version it derives',
).toBe(false)
// ...and it is still kept out of the package itself. Both must hold: the
// tempting "fix" for either half is to make the two lists one again.
expect(packaging('ignore')).toContain('scripts')
})
it('keeps its own scripts and specs out of the XPI', () => {
@@ -77,22 +107,71 @@ describe('consumers delegate rather than keeping their own copy', () => {
}
})
it('ci.yml derives its pathspec from the script and hardcodes none', () => {
const ci = readText('..', '.forgejo', 'workflows', 'ci.yml')
expect(ci).toContain('extension/scripts/packaging.sh pathspec')
// A literal :(exclude)extension/... in the workflow means someone bypassed
// the shared definition.
expect(ci).not.toMatch(/:\(exclude\)extension\//)
// Read from disk rather than listed by hand. The point of this assertion is
// that it survives consumers coming and going, and a hardcoded list is the
// one part of it that cannot — release.yml (milestone 318 step 7) would have
// joined the directory without joining the check.
const WORKFLOWS = readdirSync(path.join(EXT_DIR, '..', '.forgejo', 'workflows')).filter((f) =>
f.endsWith('.yml')
)
it('no workflow hardcodes the packaged-file set', () => {
// ci.yml used to substitute `packaging.sh pathspec` directly, for the
// manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
// set today, and nothing should start to: a literal :(exclude)extension/...
// in a workflow means someone bypassed the shared definition, which is
// exactly the drift #2397 was about.
expect(
WORKFLOWS.length,
'no workflows found — the glob is not looking where it thinks'
).toBeGreaterThan(2)
for (const wf of WORKFLOWS) {
const text = readText('..', '.forgejo', 'workflows', wf)
expect(text, `${wf} inlines an :(exclude) literal`).not.toMatch(/:\(exclude\)extension\//)
}
})
it('build.yml takes the shipped version from the script, not from the repo', () => {
// The version is DERIVED from commit time (#3092, milestone 271 step 4).
// Going back to reading the committed value is not a style regression, it
// is the bug: a hand-set version makes dev and main sign the same number
// for different code, and the ext-<version> cache then serves one channel
// the other's XPI.
const build = readText('..', '.forgejo', 'workflows', 'build.yml')
expect(build).toContain('packaging.sh version')
expect(build, 'build.yml re-reads the committed version instead of deriving it')
.not.toMatch(/grep[^\n]*'"version"'[^\n]*package\.json/)
})
})
describe('extension version', () => {
it('keeps manifest.json and package.json in lockstep', () => {
expect(read('manifest.json').version).toBe(read('package.json').version)
// Mozilla's published grammar for addons.mozilla.org, transcribed from MDN's
// manifest.json/version page. Each segment is the single digit 0 or starts
// 1-9 — so no leading zeros — and there are at most four of them.
const AMO = /^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$/
it('keeps a committed version AMO would accept, though it ships nothing', () => {
// The committed value is wholly inert since milestone 318 step 8: there is
// no hand-set MAJOR.MINOR left for packaging.sh to read, and build.yml
// stamps the derived string over both files before web-ext sees them.
//
// It is still asserted, for one reason: `npm run build` locally packages
// whatever is committed, so a value AMO would reject turns a local build
// into a confusing failure with no CI signal ahead of it. ci.yml checks
// the same grammar against the DERIVED value, which is the one AMO sees.
for (const file of ['manifest.json', 'package.json']) {
expect(read(file).version, `${file} version is not AMO-shaped`).toMatch(AMO)
}
})
it('uses a plain dotted numeric version AMO will accept', () => {
expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/)
it('rejects the zero-padded family shape, which is why the extension unpads', () => {
// Guards the reason for the exception, not just its result. If this ever
// starts passing, someone has loosened the pattern and the next sign burns
// an AMO version to find out. (#3138.)
expect('2026.08.29.0201').not.toMatch(AMO)
expect('2026.8.29.201').toMatch(AMO)
// Five segments: AMO allows four.
expect('2026.8.29.2.1').not.toMatch(AMO)
})
it('declares manifest v3', () => {
@@ -4,6 +4,16 @@
<span v-if="manifest?.installed" class="text-caption fc-muted">
· Firefox · v{{ manifest.version }}
</span>
<!-- Which channel this instance serves, so it is visible without
installing anything. Rendered only when the image declares one: a
locally-built image, or one predating the field, says nothing rather
than guessing. Never merged into the version string beside it see
the endpoint's note on why a `-dev` suffix breaks the comparator. -->
<v-chip
v-if="manifest?.channel"
size="x-small" variant="tonal" class="ml-2"
:color="manifest.channel === 'dev' ? 'warning' : 'info'"
>{{ manifest.channel }}</v-chip>
</CardHeading>
<v-card-text>
+25 -1
View File
@@ -5,6 +5,18 @@ import { useApi } from '../composables/useApi.js'
export const useSystemStore = defineStore('system', () => {
const api = useApi()
const healthy = ref(null) // null=unknown, true=ok, false=down
// What the instance says it is. Since milestone 318 stopped publishing
// version image tags, this is the only answer to "which build is this?" —
// there is no registry name left to check it against.
//
// Three states, and collapsing any two of them would lie:
// buildLoaded=false we have not asked yet -> render nothing
// buildLoaded=true, version='' the build cannot say -> render "unknown"
// buildLoaded=true, version=x this build is x
// A blank footer would read as "no version", which is a different claim.
const buildVersion = ref('')
const buildChannel = ref('')
const buildLoaded = ref(false)
const stats = ref(null)
const statsLoading = ref(false)
@@ -12,8 +24,17 @@ export const useSystemStore = defineStore('system', () => {
try {
const body = await api.get('/api/health')
healthy.value = body.status === 'ok'
// Absent means "cannot say" — the server omits these rather than
// sending empty strings, so `?? ''` preserves that rather than
// inventing a value for it.
buildVersion.value = body.version ?? ''
buildChannel.value = body.channel ?? ''
buildLoaded.value = true
} catch {
healthy.value = false
// Deliberately NOT setting buildLoaded: a failed health call tells us
// nothing about the build, and claiming "unknown" would present a
// network blip as a defective image.
}
}
@@ -26,5 +47,8 @@ export const useSystemStore = defineStore('system', () => {
}
}
return { healthy, stats, statsLoading, refreshHealth, refreshStats }
return {
healthy, stats, statsLoading, refreshHealth, refreshStats,
buildVersion, buildChannel, buildLoaded,
}
})
+15
View File
@@ -54,6 +54,21 @@
<MaintenancePanel />
</v-window-item>
</v-window>
<!-- Which build is this? With no version image tags (milestone 318) the
instance's own report is the only answer, so it is shown rather than
hidden. The instinct to treat it as information disclosure does not
survive contact: the JS bundle and asset hashes fingerprint the build
anyway, and "I'm on 2026.08.28.1249" is the single most useful line in
a bug report.
Channel sits BESIDE the version, never inside it (rule 149) — a
`-dev` suffix would read as a 0 segment to the extension's comparator
and make every dev build compare equal (#2993). -->
<div v-if="system.buildLoaded" class="text-caption text-medium-emphasis text-center mt-8">
FabledCurator {{ system.buildVersion || 'unknown' }}
<span v-if="system.buildChannel"> · {{ system.buildChannel }}</span>
</div>
</v-container>
</template>
@@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { nextTick } from 'vue'
import BrowserExtensionCard from '../../src/components/settings/BrowserExtensionCard.vue'
import { freshPinia, mountComponent } from '../support/mountComponent.js'
// useApi is a thin fetch wrapper, so the seam is fetch itself (same shape as
// showcase.spec.js) rather than a module mock.
function stubApi(manifest) {
globalThis.fetch = vi.fn(async (url) => {
const payload = String(url).includes('/api/extension/manifest')
? manifest
: { key: 'test-key' }
return {
ok: true, status: 200, statusText: '200',
text: async () => JSON.stringify(payload),
}
})
}
async function mountCard(manifest) {
stubApi(manifest)
const w = mountComponent(BrowserExtensionCard, { pinia: freshPinia() })
// onMounted fires two fetches (manifest + key) and each resolves through a
// chain of microtasks. Yielding to a macrotask drains the whole queue, which
// a fixed number of nextTicks would only do by luck.
await new Promise((resolve) => setTimeout(resolve, 0))
await nextTick()
return w
}
const INSTALLED = {
installed: true,
version: '1.0.3499884',
xpi_url: '/extension/fabledcurator-1.0.3499884.xpi',
latest_url: '/extension/fabledcurator-latest.xpi',
sha256: 'abc',
}
describe('BrowserExtensionCard — channel', () => {
beforeEach(() => { vi.restoreAllMocks() })
afterEach(() => { delete globalThis.fetch })
it('names the channel the instance reports', async () => {
// The point of the whole channel scheme: an operator can tell a dev
// instance from a main one without installing anything.
const w = await mountCard({ ...INSTALLED, channel: 'dev' })
expect(w.text()).toContain('dev')
})
it('shows the version and the channel as SEPARATE text, never merged', async () => {
// Regression guard with teeth: the tempting shortcut is a `-dev` version
// suffix, and that is precisely what breaks the extension's comparator —
// it parses each dotted segment with parseInt, so a suffixed segment reads
// as 0 and every dev build compares equal to every other. If someone ever
// "simplifies" by folding the channel into the version, the version text
// stops being the bare derived number and this fails.
const w = await mountCard({ ...INSTALLED, channel: 'dev' })
expect(w.text()).toContain('v1.0.3499884')
expect(w.text()).not.toContain('1.0.3499884-dev')
})
it('renders no channel when the instance declares none', async () => {
// A locally-built image, or one predating the field. The card must read
// exactly as it did before the channel existed rather than inventing an
// "unknown" badge — absence is a normal answer here, not a fault.
const w = await mountCard(INSTALLED)
expect(w.text()).toContain('v1.0.3499884')
expect(w.findAll('v-chip')).toHaveLength(0)
})
})
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useSystemStore } from '../src/stores/system.js'
// Which build am I running? Milestone 318 stopped publishing version image
// tags, so the instance's own report is the ONLY answer — there is no registry
// name left to check it against. That promotes this from a convenience to the
// mechanism, and it means the three states below have to stay distinct: a
// wrong answer here has nothing to contradict it.
//
// not asked yet -> render nothing
// asked, no version -> render "unknown"
// asked, has a version -> render it
//
// Collapsing the first two would show "unknown" during every page load, and
// collapsing either into a blank would read as "no version", which is a
// different and false claim.
function stubHealth(body, { fail = false } = {}) {
globalThis.fetch = vi.fn(async () => {
if (fail) throw new Error('network down')
return {
ok: true, status: 200, statusText: '200',
text: async () => JSON.stringify(body),
}
})
}
describe('system store — build identity', () => {
beforeEach(() => setActivePinia(createPinia()))
afterEach(() => { vi.restoreAllMocks(); delete globalThis.fetch })
it('starts having asked nothing, so the footer renders nothing', () => {
const s = useSystemStore()
expect(s.buildLoaded).toBe(false)
})
it('reports the version and channel the instance claims', async () => {
stubHealth({ status: 'ok', version: '2026.08.28.1249', channel: 'dev' })
const s = useSystemStore()
await s.refreshHealth()
expect(s.buildLoaded).toBe(true)
expect(s.buildVersion).toBe('2026.08.28.1249')
expect(s.buildChannel).toBe('dev')
})
it('keeps the channel OUT of the version string', async () => {
// The tempting shortcut is a `-dev` suffix. The extension's comparator
// parses each dotted segment with parseInt, so a suffixed segment reads as
// 0 and every dev build compares equal to every other — #2993 exactly
// (rule 149). If anyone ever "simplifies" by folding them together, the
// version stops being the bare derived number and this fails.
stubHealth({ status: 'ok', version: '2026.08.28.1249', channel: 'dev' })
const s = useSystemStore()
await s.refreshHealth()
expect(s.buildVersion).toBe('2026.08.28.1249')
expect(s.buildVersion).not.toContain('dev')
})
it('treats an absent version as "cannot say", not as a value', async () => {
// A locally-built image, or one predating the field. The server omits the
// key rather than sending an empty string; `?? ''` must preserve that
// rather than inventing something. The view renders "unknown" from it.
stubHealth({ status: 'ok' })
const s = useSystemStore()
await s.refreshHealth()
expect(s.buildLoaded).toBe(true)
expect(s.buildVersion).toBe('')
expect(s.buildChannel).toBe('')
})
it('reports a version with no channel without inventing one', async () => {
stubHealth({ status: 'ok', version: '2026.08.28.1249' })
const s = useSystemStore()
await s.refreshHealth()
expect(s.buildVersion).toBe('2026.08.28.1249')
expect(s.buildChannel).toBe('')
})
it('does not claim "unknown" when the health call itself failed', async () => {
// A network blip says nothing about the build. Marking it loaded here
// would present a transient failure as a defective image — and since
// nothing else names the build, there would be no second source to
// correct the impression.
stubHealth(null, { fail: true })
const s = useSystemStore()
await s.refreshHealth()
expect(s.healthy).toBe(false)
expect(s.buildLoaded).toBe(false)
})
})
+201
View File
@@ -0,0 +1,201 @@
#!/bin/sh
# Single definition of WHAT EACH PUBLISHED ARTIFACT IS BUILT FROM, and the
# version derived from it. Milestone 313; generalises the shape
# extension/scripts/packaging.sh established for the extension alone.
#
# "Built from" is deliberately wider than "copied into". A file that DECIDES an
# artifact's identity is part of what that artifact is built from even though it
# never reaches the image — see DERIVER below, and #3156 for the same finding
# about packaging.sh.
#
# Four artifacts, four independent versions. An artifact whose shipped files
# did not change keeps its version and does not rebuild — that is the whole
# point, and it is why each path set must match its Dockerfile rather than
# being a plausible guess. Getting a set wrong is quiet in BOTH directions:
#
# too narrow -> a pin serves stale bytes, because the version did not move
# when the content did. This is the dangerous one.
# too wide -> the artifact re-versions and rebuilds for a change it does
# not ship. Merely wasteful.
#
# tests/test_artifact_paths.py asserts every COPY source in each Dockerfile is
# covered here, so adding a COPY without updating this file fails CI.
#
# POSIX sh only — CI's run shell is busybox on some paths.
#
# -f (no pathname expansion) is load-bearing for the whole script: the lists
# below are iterated with deliberate word-splitting, and without it the shell
# would glob `frontend/test/**` against the working tree and silently narrow
# the pattern. Callers substituting the output need their own `set -f` too;
# the two guards protect different expansions.
set -euf
ROOT=$(git rev-parse --show-toplevel)
# --- what each artifact ships ------------------------------------------------
#
# Each set includes its own Dockerfile and requirements: changing a base image
# or a pin changes the artifact just as surely as changing a source file.
#
# web (Dockerfile, context `.`) — the runtime stage copies backend/, alembic/,
# alembic.ini, entrypoint.sh and requirements.txt; the frontend-builder stage
# copies frontend/ and the runtime takes its `dist` output.
#
# frontend/test is excluded: `npm run build` is vite, which builds from src/,
# index.html and public/ and never reads test/. It lands in the builder layer
# but not in `dist`, so it cannot reach the shipped image.
#
# The web image ALSO bundles the signed XPI (build.yml downloads it into
# frontend/public/extension/ before the docker build), so an extension change
# changes the web image. The extension's packaged set is appended in cmd_paths
# rather than restated — one definition, per #2397.
WEB_PATHS='Dockerfile requirements.txt backend alembic alembic.ini entrypoint.sh frontend :(exclude)frontend/test :(exclude)frontend/test/**'
# ml (Dockerfile.ml, context `.`) — no frontend, no extension. Note it copies
# BOTH requirements-ml.txt and requirements.txt.
ML_PATHS='Dockerfile.ml requirements-ml.txt requirements.txt backend alembic alembic.ini entrypoint.sh'
# agent (agent/Dockerfile, context `agent`) — copies requirements.txt and
# fc_agent only. agent/README.md, agent/docker-compose.yml and agent/ruff.toml
# live in the directory but never reach the image, so they must not re-version
# it: this is deliberately NOT `agent/`.
AGENT_PATHS='agent/Dockerfile agent/requirements.txt agent/fc_agent'
# This file. It is copied into no image and it is still part of what the web
# image is built from, because it DECIDES the FC_VERSION baked into that image
# (#3202). Same finding as #3156 about packaging.sh, one level up.
#
# Why web and nothing else. Every artifact stamps `fc.revision`, but only web
# also stamps a version (build.yml line ~488 feeds `version web` to the
# FC_VERSION build arg; ml and agent ask for `revision` alone, and the
# extension takes its version from packaging.sh). For a revision-only artifact
# this file needs no entry: any change to how the revision is COMPUTED changes
# the derived value, which then disagrees with the label on the published image
# and forces a rebuild. That mechanism is self-correcting because it compares
# against a string stamped into a real artifact.
#
# The version is compared against nothing, so it has no such backstop. Before
# this entry, a change to cmd_version alone left every artifact's revision
# untouched, the reuse check hit, the build was skipped, and the published
# image went on reporting the OLD version format — silently, until some
# unrelated commit happened to force a rebuild. Milestone 318 step 5 is the
# worked instance: b3989d0 and 5771fd5 share revision fb2c4d5b80be while the
# version moved 2026.8.28.1249 -> 2026.08.28.1249. It cost nothing only because
# FC_VERSION did not exist until one commit later.
#
# Named as a file, not as `scripts`: release_notes.py lives beside it and only
# READS derived values, so it decides nothing and must not re-version anything.
# A future script that derives an identity belongs here explicitly.
DERIVER='scripts/artifacts.sh'
usage() {
echo "usage: artifacts.sh {paths|revision|version} {web|ml|agent|extension}" >&2
exit 2
}
# The extension's packaged set, read from its own definition rather than
# copied. packaging.sh emits `:(exclude)extension/...` entries, so the bare
# `extension` include has to come with them.
ext_paths() {
echo "extension $(sh "$ROOT/extension/scripts/packaging.sh" pathspec)"
}
cmd_paths() {
case "$1" in
web) echo "$WEB_PATHS $DERIVER $(ext_paths)" ;;
ml) echo "$ML_PATHS" ;;
agent) echo "$AGENT_PATHS" ;;
extension) ext_paths ;;
*) usage ;;
esac
}
# "<unix ts> <sha>" of the newest commit touching this artifact's shipped set.
# Unquoted on purpose: the pathspec must word-split into separate args.
# Globbing is already off script-wide.
newest() {
# shellcheck disable=SC2046
set -- "$(cd "$ROOT" && git log --format='%ct %H' HEAD -- $(cmd_paths "$1") \
| sort -n | tail -1)"
if [ -z "$1" ]; then
echo "artifacts.sh: no commit touches this artifact's shipped files" >&2
exit 1
fi
echo "$1"
}
# Formatted through git rather than date(1): busybox date does not reliably
# accept `-d @<epoch>`, and git's own --date=format-local is available wherever
# git is. TZ=UTC so the value does not depend on the runner's timezone.
fmt() {
(cd "$ROOT" && TZ=UTC git show -s --format=%cd --date="format-local:$2" "$1")
}
# The IDENTITY of an artifact's content: the commit its shipped files last
# changed in. This is what decides whether a build can be skipped.
#
# It is published as the `fc.revision` LABEL on the image itself, and read
# back off the moving channel tag — not as a tag of its own (milestone 318
# step 3). A tag would be a name minted per build that only one thing reads,
# which is what rule 145 narrowed against; it would also be prunable under the
# registry's keep_pattern (#3157), so the cache would silently expire.
#
# A published image with no such label reads as a MISS and rebuilds. That is
# the migration path, not a fault: `imagetools create` copies a manifest and
# config labels are not manifest annotations, so the reuse path cannot stamp
# one and there is nothing to backfill. Each artifact pays one rebuild, once.
cmd_revision() {
echo "$(newest "$1")" | cut -d' ' -f2 | cut -c1-12
}
# The VERSION: `YYYY.MM.DD.HHMM`, zero-padded, UTC. One shape across the whole
# family (note #3127 §1, rule 148) — the number an instance reports about
# itself, and, with a `v` in front, the release tag naming the same build.
#
# Zero-padded since 2026-08-28. This stripped leading zeros until then, on the
# reasoning that every segment should read as a plain integer — which never
# held, since comparison strips them on parse anyway. Padding costs nothing,
# sorts lexically as well as numerically, and keeps this project emitting the
# same string as its siblings: unpadded, a `2026.8.28.1432` here sits beside a
# `2026.08.28.1432` there, two shapes one character apart. Two obviously
# different formats are safer than two nearly identical ones.
#
# Comparison is numeric per dot-segment, so `08` and `8` are equal and nothing
# already published is reordered by the change.
#
# HHMM is not decoration: it is what makes the value unique per build with no
# lookup. A date alone collides on the second build of a day, and resolving
# that needs a `.N` suffix, which needs asking the registry what already
# exists — at which point two lanes derive different answers for one source
# and the shared-signature property is lost.
cmd_version() {
# The extension is the one artifact this script does not FORMAT, only route.
# AMO's version grammar forbids leading zeros, so the extension emits the
# same numbers unpadded (#3138) — a rendering exception, documented in
# packaging.sh beside the signing step that has to obey it. Delegating keeps
# one answer per artifact: `artifacts.sh version extension` and
# `packaging.sh version` cannot drift into two.
#
# The direction is deliberate. artifacts.sh already asks packaging.sh for the
# extension's PATH SET (ext_paths above), so the version has to flow the same
# way; reversing it would have packaging.sh call back into this script, which
# would call packaging.sh for the paths again.
if [ "$1" = extension ]; then
sh "$ROOT/extension/scripts/packaging.sh" version
return
fi
sha=$(echo "$(newest "$1")" | cut -d' ' -f2)
# One git call for the whole string rather than four and a sed. git's
# format-local takes the complete format, and doing it in pieces was only
# ever there to strip the padding between them.
fmt "$sha" '%Y.%m.%d.%H%M'
}
[ $# -ge 2 ] || usage
case "$1" in
paths) cmd_paths "$2" ;;
revision) cmd_revision "$2" ;;
version) cmd_version "$2" ;;
*) usage ;;
esac
+312
View File
@@ -0,0 +1,312 @@
"""Publish a Forgejo release whose body is derived from git, not written by hand.
Milestone 318 step 2 took the build consequence away from a `v*` tag: `main`
has already built and published the commit by the time anyone tags it, and
rebuilding would re-push `:c-<sha>`, which rule 145 forbids even when the bytes
match. That left the tag with nothing to do. This gives it the job it has left.
**The half of the question a version string cannot answer.** Step 6 puts
`2026.08.28.2208` in the Settings footer, so an operator can say which build
they are running. They still cannot say what is in it that was not in the one
they ran last month. A dated release carrying the commits since the previous
one is the object that interprets the identifier (note #3127 §5).
**Derived, so it cannot drift.** The alternative is a hand-maintained
`CHANGELOG.md`, which goes aspirational the first time someone forgets — and
nothing ever catches it, because there is no second source to disagree with.
Every line below comes out of `git log` at publish time.
**Optional by construction.** Release tags are bookmarks: cut one when you will
want to point at that day by name, otherwise don't. FC went twelve weeks
without one and nothing was wrong (note #3127 §0). This runs on a tag push and
on nothing else — deliberately no schedule and no auto-tag on merge, either of
which would turn an optional bookmark back into ceremony.
## Finding the previous release
`git describe --exclude <this tag>`, which walks ANCESTRY, not a sorted list.
That is not fussiness: this repo's existing tags are the old `v26.05.22.0`
shape and the next one will be rule 148's `v2026.08.28.2208`. Lexicographically
`v2026...` sorts BEFORE `v26...` — every release from here on would report its
predecessor as itself-or-nothing and emit a changelog covering the entire
history. Ancestry is immune to the shape change, and it is also the more honest
question: "what is in this that was not in the last one" IS a reachability
question.
## Re-runs update, they do not fall through
Note #3127 §6.7: a publisher that POSTs and recovers the id from a `409` never
rewrites the body, so a re-run silently keeps the first version. Harmless for a
`v*` tag created once — and wrong the moment anything re-points. This one GETs
first and PATCHes when the release exists, so it is correct either way rather
than correct by luck (ThoughtSync #2182 is the same bug).
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.request
API = "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator"
IMAGES = (
"git.fabledsword.com/bvandeusen/fabledcurator",
"git.fabledsword.com/bvandeusen/fabledcurator-ml",
"git.fabledsword.com/bvandeusen/fabledcurator-agent",
)
# Rule 148: `v` + the artifact's own version, zero-padded, no `.N`, no lookup.
RULE_148 = re.compile(r"^v\d{4}\.\d{2}\.\d{2}\.\d{4}$")
# Past this, the list has stopped being something anyone reads. It is reached
# in exactly one situation — no previous tag is reachable, so the span is the
# whole history — which happens on a genuine first release and on a tag cut
# somewhere `main`'s tags cannot be seen from. Truncating says so; emitting
# 1100 lines would bury the note explaining why there are 1100 of them.
MAX_COMMITS = 200
def git(*args: str) -> str:
return subprocess.run(
["git", *args], capture_output=True, text=True, check=True
).stdout.strip()
def git_ok(*args: str) -> str | None:
"""Run git, returning None instead of raising when it fails.
Used for the questions that legitimately have no answer — no previous tag,
no local `main` — where the absence is information rather than a fault.
"""
try:
return git(*args)
except subprocess.CalledProcessError:
return None
def previous_tag(ref: str, tag: str | None) -> str | None:
"""The most recent `v*` tag reachable from `ref`, excluding `tag` itself.
`--exclude` rather than `<ref>^` so this is the same call whether or not
`ref` is the tag being released — and so it does not blow up on a root
commit that has no parent to walk to.
"""
args = ["describe", "--tags", "--abbrev=0", "--match", "v*"]
if tag:
args += ["--exclude", tag]
return git_ok(*args, ref)
def commits(previous: str | None, ref: str) -> list[str]:
"""The subjects between the previous release and this one.
`--no-merges` because rule 153 merges `dev` into `main` with a plain merge
commit, so `main`'s first-parent view is a list of "Merge pull request #N"
and nothing else. The work is in the commits under those merges.
"""
span = f"{previous}..{ref}" if previous else ref
out = git("log", "--no-merges", "--format=%s (%h)", span)
return [line for line in out.split("\n") if line.strip()]
def truncate(log: list[str]) -> tuple[list[str], str | None]:
if len(log) <= MAX_COMMITS:
return log, None
return log[:MAX_COMMITS], (
f"{len(log)} commits in this span — more than a changelog is for. "
f"Listing the newest {MAX_COMMITS}. This usually means no previous "
f"`v*` tag was reachable from here."
)
def render(tag: str, sha: str, previous: str | None, log: list[str], notes: list[str]) -> str:
short = sha[:7]
parts = []
if notes:
# Anything the derivation could not stand behind goes at the TOP, not
# in a footnote. A release that quietly names a build nobody can find
# is the failure this whole milestone is about.
parts.append("\n".join(f"> **Note:** {n}" for n in notes))
parts.append(
f"Built from `{short}`. The rollback unit is the immutable `:c-` tag "
f"(rule 145) — these three move together:\n\n```\n"
+ "\n".join(f"{image}:c-{short}" for image in IMAGES)
+ "\n```"
)
heading = f"## Changes since {previous}" if previous else "## Changes"
if log:
parts.append(heading + "\n\n" + "\n".join(f"- {line}" for line in log))
else:
parts.append(
heading
+ "\n\n_No non-merge commits since the previous release. This tag "
"names the same source under a new name._"
)
span = f"{previous}..{tag}" if previous else tag
parts.append(
f"---\n\n_Derived at publish time from `git log --no-merges {span}`. "
f"Nothing here is hand-maintained._"
)
return "\n\n".join(parts)
def cross_checks(tag: str, sha: str) -> list[str]:
"""Everything the derivation knows that would make the release a lie.
Reported rather than enforced. The tag is already pushed by the time this
runs, so failing here would leave the operator with a tag and no release
and nothing but a red lane to explain it — while the release itself is
still the useful object. Say what is wrong, on the release, and publish.
"""
notes = []
if not RULE_148.match(tag):
notes.append(
f"`{tag}` is not rule 148's `vYYYY.MM.DD.HHMM` shape. Published "
f"anyway — the old `v26.*` tags predate the rule."
)
else:
derived = artifact_version("web")
if derived and derived != tag[1:]:
notes.append(
f"This tag names `{tag[1:]}`, but the web image built from "
f"`{sha[:7]}` reports `{derived}`. The Settings footer will not "
f"match this release's name."
)
# `:c-<sha>` only exists if `main` built this commit. Checking costs one
# git call; claiming it without checking costs a rollback that 404s at the
# moment someone needs it.
main = git_ok("rev-parse", "--verify", "-q", "refs/remotes/origin/main")
if main is None:
notes.append(
"Could not resolve `origin/main` here, so the `:c-` tags above are "
"unverified — they exist only if `main` built this commit."
)
elif subprocess.run(
["git", "merge-base", "--is-ancestor", sha, main], capture_output=True
).returncode != 0:
notes.append(
f"`{sha[:7]}` is not on `main`, so no `:c-{sha[:7]}` images were "
f"ever published. The refs above will not pull."
)
return notes
def artifact_version(artifact: str) -> str | None:
"""What `artifacts.sh` derives for one artifact in the CURRENT checkout.
It takes no ref because `artifacts.sh` takes none — it walks history from
HEAD. That is right here only because a tag push checks out the tagged
commit; calling this after `--dry-run some-other-ref` would compare the
tag against the working tree, which is why the mismatch note below is
reported and not enforced.
Returns None rather than raising if the script is missing or unhappy: a
cross-check that cannot run should not take the release down with it.
"""
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
try:
return subprocess.run(
["sh", os.path.join(root, "scripts", "artifacts.sh"), "version", artifact],
capture_output=True, text=True, check=True, cwd=root,
).stdout.strip()
except (subprocess.CalledProcessError, OSError):
return None
def api(method: str, path: str, token: str, payload: dict | None = None) -> dict | None:
body = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
API + path, data=body, method=method,
headers={
"Authorization": "token " + token,
"Content-Type": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return json.load(resp)
except urllib.error.HTTPError as exc:
if exc.code == 404:
return None
sys.exit(f"release: {method} {path} failed with HTTP {exc.code}: {exc.read()!r}")
def publish(tag: str, name: str, body: str, token: str) -> None:
existing = api("GET", f"/releases/tags/{tag}", token)
if existing:
api("PATCH", f"/releases/{existing['id']}", token, {"name": name, "body": body})
print(f"release: updated existing release {existing['id']} for {tag}")
else:
api("POST", "/releases", token, {"tag_name": tag, "name": name, "body": body})
print(f"release: created release for {tag}")
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument(
"ref", nargs="?", default=None,
help="tag or commit to release. Defaults to GITHUB_REF's tag, else HEAD.",
)
ap.add_argument(
"--dry-run", action="store_true",
help="render the body to stdout and publish nothing. Needs no token, "
"so it also works as a preview before you decide to cut the tag.",
)
args = ap.parse_args()
github_ref = os.environ.get("GITHUB_REF", "")
if args.ref:
ref = args.ref
elif github_ref.startswith("refs/tags/"):
ref = github_ref[len("refs/tags/"):]
else:
ref = "HEAD"
# A tag only if git knows it as one — `HEAD` and a raw sha are refs to
# release FROM, never the name to exclude or to publish under.
tag = ref if git_ok("rev-parse", "--verify", "-q", f"refs/tags/{ref}") else None
sha = git("rev-parse", ref)
previous = previous_tag(ref, tag)
print(f"release: ref={ref} sha={sha[:12]} previous={previous or '<none>'}")
notes = cross_checks(tag, sha) if tag else [
f"Rendered for `{ref}`, which is not a tag. Nothing was published."
]
for note in notes:
print(f"release: NOTE {note}")
log = commits(previous, ref)
print(f"release: {len(log)} non-merge commits in the span")
log, overflow = truncate(log)
if overflow:
print(f"release: NOTE {overflow}")
notes.append(overflow)
body = render(tag or ref, sha, previous, log, notes)
if args.dry_run or not tag:
print("--- body ---")
print(body)
return
token = os.environ.get("RELEASE_TOKEN") or os.environ.get("TOKEN")
if not token:
sys.exit("release: no RELEASE_TOKEN in the environment")
publish(tag, f"FabledCurator {tag[1:]}", body, token)
if __name__ == "__main__":
main()
+47
View File
@@ -383,6 +383,53 @@ async def test_extension_manifest_returns_metadata_when_xpi_present(client, monk
assert body["sha256"] == hashlib.sha256(b"fake-xpi-content").hexdigest()
@pytest.mark.asyncio
async def test_extension_manifest_reports_the_channel_the_image_declares(
client, monkeypatch, tmp_path
):
"""The channel travels BESIDE the version, never inside it.
Folding it in as a `1.0.3499884-dev` suffix is the failure this design
exists to avoid: the extension's comparator parses each dotted segment as
an integer, so a suffixed segment collapses to 0 and every dev build
compares equal to every other — "no update available" and "I cannot read
this version" stop being distinguishable. Asserting the two are separate
keys is what keeps a future edit from merging them.
"""
(tmp_path / "fabledcurator-1.2.3.xpi").write_bytes(b"x")
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path)
monkeypatch.setattr(extension_module, "FC_CHANNEL", "dev")
resp = await client.get("/api/extension/manifest")
assert resp.status_code == 200
body = await resp.get_json()
assert body["channel"] == "dev"
assert body["version"] == "1.2.3"
@pytest.mark.asyncio
async def test_extension_manifest_omits_the_channel_when_the_image_declares_none(
client, monkeypatch, tmp_path
):
"""A local build, or any image from before the field existed.
The key must be ABSENT rather than present-and-empty: absence is the state
every consumer already handles (an older image conveys it by not having the
key at all), so a blank channel reuses that path instead of introducing a
second spelling of "unknown" for each reader to special-case.
"""
(tmp_path / "fabledcurator-1.2.3.xpi").write_bytes(b"x")
monkeypatch.setattr(extension_module, "XPI_DIR", tmp_path)
monkeypatch.setattr(extension_module, "FC_CHANNEL", "")
resp = await client.get("/api/extension/manifest")
assert resp.status_code == 200
body = await resp.get_json()
assert "channel" not in body
# Everything else still answers — an image with no channel is not a
# degraded one, it just cannot say which channel it came from.
assert body["installed"] is True
assert body["latest_url"] == "/extension/fabledcurator-latest.xpi"
# --- /extension/<filename> -----------------------------------------
+236
View File
@@ -0,0 +1,236 @@
"""The two values `artifacts.sh` derives, and what each of them promises.
`revision` decides whether a build gets skipped; `version` is what an instance
reports about itself and what a release tag is named after. Neither has a
consumer that would notice it going subtly wrong.
## revision
Milestone 318 step 3: each image carries its revision as an `fc.revision`
label, and build.yml reads that label back off the moving channel tag. Equal
to the derived revision means the bytes this push would produce are already
published, so the build is skipped.
That makes the revision load-bearing in a way a version string is not — it is
compared for equality against a value stamped into a real published artifact.
Both ways of getting it wrong are silent:
* **it does not identify the content** — a revision that moves when the source
did not (a HEAD-derived value, say) never matches, nothing is ever skipped,
and the mechanism quietly buys nothing while every lane stays green.
* **it identifies the wrong content** — a revision that holds still when the
source DID change matches a stale label, the build is skipped, and the
channel serves bytes that do not correspond to the commit. This is the
dangerous direction, and it is what `test_artifact_paths.py` guards from the
other side by pinning the path sets.
This module owns the narrower claim: whatever the path sets say, the revision
is genuinely the commit those paths last changed in.
## version
`YYYY.MM.DD.HHMM`, zero-padded, UTC — one shape across the family (note #3127
§1, rule 148), so the string this project emits is the same string its siblings
emit. Two nearly-identical formats are more dangerous than two obviously
different ones, and the only thing keeping them identical is a test.
**The extension is the one exception, and it is a rendering exception only.**
AMO's version grammar forbids a leading zero, so the extension emits the same
numbers unpadded — `2026.8.29.201` where the family says `2026.08.29.0201`
(#3138, milestone 318 step 8). Rule 148 defines comparison as numeric per
dot-segment, under which the two are equal, so this is pinned in both
directions below: the extension must satisfy AMO's grammar, and every artifact
must derive the same NUMBERS its own commit stamps. An exception left as "the
extension is different" would drift into being differently different.
The identity-TAG tests this file used to hold are gone with the tag. There is
no longer a `CHANNELLED` list to drift (the channel is which tag you inspect),
and no `identity` subcommand to refuse an unqualified call.
"""
from __future__ import annotations
import os
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
ARTIFACTS = ("web", "ml", "agent", "extension")
# 12 hex chars — the prefix build.yml stamps and compares.
_REVISION = re.compile(r"^[0-9a-f]{12}$")
# YYYY.MM.DD.HHMM, every segment zero-padded to its full width.
_VERSION = re.compile(r"^\d{4}\.\d{2}\.\d{2}\.\d{4}$")
# The artifacts that cannot use the padded rendering. Exactly one, and the
# reason is external: `packaging.sh` derives the extension's version and AMO
# refuses to sign a padded one.
AMO_UNPADDED = frozenset({"extension"})
# Mozilla's published grammar for addons.mozilla.org, transcribed from MDN's
# manifest.json/version page. A segment is the single digit `0` or starts 1-9,
# and there are at most four. This is the constraint the exception exists for,
# so it is what the exception is tested against — `2026.08.29.0201` fails it.
_AMO = re.compile(r"^(0|[1-9][0-9]{0,8})(\.(0|[1-9][0-9]{0,8})){0,3}$")
# YYYY.M.D.HHMM — four segments, none of them zero-padded.
_UNPADDED = re.compile(r"^\d{4}(\.(0|[1-9]\d*)){3}$")
def segments(value: str) -> tuple[int, ...]:
"""A version as the numbers it denotes, which is how rule 148 says to
compare one. `2026.08.29.0201` and `2026.8.29.201` are one value here."""
return tuple(int(part) for part in value.split("."))
# Everything here goes through artifacts.sh rather than importing a sibling
# test module. That is the interface build.yml actually calls, so the tests
# exercise the contract instead of a Python re-implementation of it — and no
# other test module in this repo imports another, so a cross-test import would
# be a new convention introduced for no gain.
def artifacts(*args: str) -> str:
return subprocess.run(
["sh", str(ROOT / "scripts" / "artifacts.sh"), *args],
capture_output=True, text=True, check=True, cwd=ROOT,
).stdout
def revision(artifact: str) -> str:
return artifacts("revision", artifact).strip()
def newest_by_commit_time(artifact: str) -> str:
"""The full SHA of the newest commit touching this artifact's shipped set.
Ordered by committer TIME, matching what artifacts.sh means. Deliberately
not `git log -1`: git's default order is reverse-chronological only within
topological constraints, so on a merged history it can name a different
commit than the newest timestamp does. They agree on this repo today, and
a test that silently depends on them continuing to agree would be a flake
waiting for the branch shape that separates them.
"""
paths = artifacts("paths", artifact).split()
log = subprocess.run(
["git", "log", "--format=%ct %H", "HEAD", "--", *paths],
capture_output=True, text=True, check=True, cwd=ROOT,
).stdout.split("\n")
commits = [line.split(" ", 1) for line in log if line.strip()]
assert commits, (
f"no commit in this history touches the {artifact} path set — the "
f"derivation has nothing to stand on"
)
return max(commits, key=lambda c: int(c[0]))[1]
@pytest.mark.parametrize("artifact", ARTIFACTS)
def test_revision_is_the_commit_its_own_shipped_files_last_changed_in(artifact):
"""The claim the whole skip decision rests on.
Computed from git rather than asked of the script, so it fails if the
derivation ever stops meaning what it says — switching to HEAD, to a build
clock, or to a path set it did not actually use. Each of those still
produces a plausible 12-hex value, which is why this is worth asserting
rather than eyeballing.
"""
expected = newest_by_commit_time(artifact)
got = revision(artifact)
assert expected.startswith(got), (
f"{artifact} derives {got!r}, but the newest commit touching its "
f"shipped files is {expected[:12]!r}. The label stamped into the image "
f"would not identify its own content."
)
@pytest.mark.parametrize("artifact", ARTIFACTS)
def test_revision_is_a_legal_label_value_and_is_stable(artifact):
"""It is stamped as a docker label and compared for string equality, so a
stray newline or a varying value breaks the comparison rather than the
build — the mechanism would simply stop hitting, silently."""
first = revision(artifact)
assert _REVISION.match(first), f"{first!r} is not a 12-char hex revision"
assert first == revision(artifact), "revision is not stable across calls"
@pytest.mark.parametrize("artifact", sorted(set(ARTIFACTS) - AMO_UNPADDED))
def test_version_is_zero_padded_calver(artifact):
"""The family shape, pinned.
Padding was stripped until 2026-08-28 on the reasoning that each segment
should read as a plain integer — which never held, since comparison strips
leading zeros on parse anyway. What it did do was make this project emit
`2026.8.28.1432` while a sibling emitted `2026.08.28.1432`: two shapes one
character apart, which is the hard kind of difference to notice.
Also catches the midnight case. A `%H%M` of `0322` must survive as `0322`;
the old strip-leading-zeros helper turned it into `322`, silently changing
a four-digit field into three.
"""
value = artifacts("version", artifact).strip()
assert _VERSION.match(value), (
f"{artifact} derives {value!r}, which is not zero-padded "
f"YYYY.MM.DD.HHMM. Note #3127 §1 and rule 148 both specify the padded "
f"form, and a release tag is this string with a `v` in front."
)
@pytest.mark.parametrize("artifact", sorted(AMO_UNPADDED))
def test_the_unpadded_artifacts_derive_something_amo_will_sign(artifact):
"""The other half of the family shape: the documented exception, tested
against the constraint that justifies it rather than against itself.
A padded value passes `_UNPADDED` on any date with no leading zeros, so
that pattern alone would let a regression sit unnoticed until the first
single-digit month — at which point the failure is a burned AMO version,
not a red lane. AMO's grammar is the assertion that fires immediately.
"""
value = artifacts("version", artifact).strip()
assert _AMO.match(value), (
f"{artifact} derives {value!r}, which AMO refuses: a segment must be "
f"the single digit `0` or start 1-9, and there are at most four. "
f"Almost certainly a zero-padded segment — the family pads and this "
f"artifact must not (#3138). AMO 409s on re-signing, so a version it "
f"rejects is burned."
)
assert _UNPADDED.match(value), (
f"{artifact} derives {value!r}, which is not YYYY.M.D.HHMM. AMO would "
f"also accept the pre-318 `1.0.<minutes>`, and that orders below every "
f"ext-2026.* release already signed."
)
@pytest.mark.parametrize("artifact", ARTIFACTS)
def test_version_and_revision_describe_the_same_commit(artifact):
"""They are derived independently and must not be able to disagree.
A build reports the version and skips on the revision, so a divergence
would mean an instance naming one commit while carrying another's bytes —
unfalsifiable from outside, since both values look perfectly well-formed.
"""
sha = newest_by_commit_time(artifact)
stamped = subprocess.run(
["git", "show", "-s", "--format=%cd", "--date=format-local:%Y.%m.%d.%H%M", sha],
capture_output=True, text=True, check=True, cwd=ROOT,
env={"TZ": "UTC", "PATH": os.environ.get("PATH", "")},
).stdout.strip()
derived = artifacts("version", artifact).strip()
# Compared as NUMBERS, which is how rule 148 defines comparison and the
# only way one assertion can cover both renderings. This is what makes the
# extension's exception cosmetic rather than semantic: it must denote
# exactly the value its own commit stamps, whatever the padding.
assert segments(derived) == segments(stamped), (
f"{artifact} derives {derived!r}, but its newest shipped commit "
f"{sha[:12]} is {stamped!r}. The instance would name one commit while "
f"carrying another's bytes."
)
if artifact not in AMO_UNPADDED:
assert derived == stamped, (
f"{artifact} derives {derived!r} where the family shape is "
f"{stamped!r} — same numbers, wrong rendering. Only the artifacts "
f"in AMO_UNPADDED may differ here."
)
assert sha.startswith(revision(artifact))
+199
View File
@@ -0,0 +1,199 @@
"""`scripts/artifacts.sh` path sets must match what the Dockerfiles copy.
Each published artifact's version derives from the newest commit touching its
own shipped file set (milestone 313). The whole scheme rests on those sets
being right, and both ways of being wrong are silent:
* **too narrow** — a file ships but is not in the set, so the version does not
move when the content does, and a pin serves stale bytes. This is the
dangerous direction and the one this module exists for.
* **too wide** — a file is in the set but never reaches the image, so the
artifact re-versions and rebuilds for a change it does not ship.
Nothing else notices either. The version still derives, CI still goes green,
and the mismatch only surfaces as "I pinned that build and got the wrong
bytes". So the Dockerfiles are read here and compared against the declaration.
The COPY list is not the whole answer, though. A file that DECIDES what an
artifact reports belongs in its set even though it is copied into nothing —
see DERIVERS below, where the same finding is recorded twice (#3156, #3202).
"""
from __future__ import annotations
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
# artifact -> (dockerfile, build context relative to the repo root)
ARTIFACTS = {
"web": ("Dockerfile", ""),
"ml": ("Dockerfile.ml", ""),
"agent": ("agent/Dockerfile", "agent"),
}
# COPY --from=<stage> copies from an earlier build stage, not from the build
# context, so its source is not a repo path and cannot be in a path set.
_COPY = re.compile(r"^\s*COPY\s+(?!--from=)(?P<args>.+)$", re.MULTILINE)
def declared_paths(artifact: str) -> list[str]:
out = subprocess.run(
["sh", str(ROOT / "scripts" / "artifacts.sh"), "paths", artifact],
capture_output=True, text=True, check=True, cwd=ROOT,
).stdout
return out.split()
def includes(artifact: str) -> list[str]:
"""The set minus its `:(exclude)…` entries."""
return [p for p in declared_paths(artifact) if not p.startswith(":(exclude)")]
def copy_sources(dockerfile: str, context: str) -> list[str]:
"""Repo-relative sources of every context COPY in a Dockerfile."""
text = (ROOT / dockerfile).read_text()
sources: list[str] = []
for m in _COPY.finditer(text):
args = m.group("args").split()
# Last arg is the destination; everything before it is a source.
for src in args[:-1]:
# `frontend/package-lock.json*` — the glob is an optional-file
# idiom; the directory it sits in is what matters for coverage.
src = src.rstrip("*")
sources.append(f"{context}/{src}" if context else src)
return sources
def covered_by(path: str, include: str) -> bool:
"""`path` ships if an include names it or one of its ancestors."""
path = path.rstrip("/").lstrip("./")
include = include.rstrip("/")
return path == include or path.startswith(include + "/")
@pytest.mark.parametrize("artifact", sorted(ARTIFACTS))
def test_every_copied_path_is_in_the_artifacts_path_set(artifact):
"""The too-narrow direction — the one that serves stale bytes on a pin."""
dockerfile, context = ARTIFACTS[artifact]
inc = includes(artifact)
for src in copy_sources(dockerfile, context):
assert any(covered_by(src, i) for i in inc), (
f"{dockerfile} copies {src!r} into the {artifact} image, but no "
f"include in scripts/artifacts.sh covers it. The {artifact} "
f"version will not move when that file changes, so a pinned build "
f"will serve stale bytes. Add it to the path set.\n"
f" declared includes: {inc}"
)
@pytest.mark.parametrize("artifact", sorted(ARTIFACTS))
def test_the_dockerfile_itself_is_in_the_path_set(artifact):
"""Changing a base image or a RUN changes the artifact as surely as
changing a source file, so each set must include its own Dockerfile."""
dockerfile, _ = ARTIFACTS[artifact]
assert any(covered_by(dockerfile, i) for i in includes(artifact)), (
f"{dockerfile} is not in the {artifact} path set — a base-image bump "
f"would not move the version."
)
def test_the_web_image_versions_on_an_extension_change():
"""The web image bundles the signed XPI, so the extension's packaged files
are part of what it ships. Miss this and `:latest` serves a NEW extension
under an unchanged web version — a pin that quietly disagrees with itself.
"""
inc = includes("web")
assert any(covered_by("extension/background/background.js", i) for i in inc), (
"the web path set does not cover the extension's packaged files, but "
"build.yml downloads the signed XPI into frontend/public/extension/ "
"before the docker build"
)
# A file that DECIDES an artifact's identity is part of what that artifact is
# built from, even though it is copied into no image. Both entries here are the
# same finding twice — #3156 for packaging.sh, #3202 for artifacts.sh — and
# both were latent for the same reason: the version has no backstop.
#
# The revision does. Change how a REVISION is computed and the derived value
# stops matching the label on the published image, which forces a rebuild; the
# mechanism self-corrects because it compares against a string stamped into a
# real artifact. Nothing compares a version to anything, so a version-only
# derivation change is invisible unless the deriver is in the set.
DERIVERS = [
# packaging.sh decides the version build.yml stamps into the packaged
# manifest.json, so changing it changes the shipped bytes. Left out,
# milestone 313 step 4 turns silent: the new version misses the
# ext-<version> cache and gets signed, while web's revision has not moved,
# so the reuse path republishes the old image and the fresh signature is
# orphaned. Guarded for web too, since web bundles what the extension makes.
("extension/scripts/packaging.sh", ("extension", "web")),
# artifacts.sh decides the FC_VERSION baked into the web image (#3202).
# Web only, and deliberately: ml and agent ask this script for `revision`
# alone, so they are covered by the self-correcting path above, and the
# extension takes its version from packaging.sh. Milestone 318 step 5 is
# the worked instance — b3989d0 and 5771fd5 share revision fb2c4d5b80be
# while the version moved 2026.8.28.1249 -> 2026.08.28.1249. It was
# harmless only because FC_VERSION did not exist until one commit later.
("scripts/artifacts.sh", ("web",)),
]
@pytest.mark.parametrize("path, artifacts", DERIVERS, ids=lambda v: str(v))
def test_a_version_deriver_is_in_the_set_of_what_it_decides(path, artifacts):
for artifact in artifacts:
inc = includes(artifact)
excluded = [
p[len(":(exclude)"):] for p in declared_paths(artifact)
if p.startswith(":(exclude)")
]
assert any(covered_by(path, i) for i in inc), (
f"{path} decides the version {artifact} reports, but is not in the "
f"{artifact} path set. A change to the derivation would leave the "
f"revision untouched, the build skipped, and the published image "
f"reporting the old version — with nothing to disagree with it."
)
assert not any(
covered_by(path, e.rstrip("*").rstrip("/")) for e in excluded
), (
f"{path} is excluded from the {artifact} path set, so a change to "
f"how the version is derived would not move the version — and "
f"step 4 would reuse the image that carries the old one"
)
@pytest.mark.parametrize(
"artifact, path",
[
# Deliberate exclusions — the too-wide direction. Each of these lives
# beside shipped code but never reaches an image, and including it
# would re-version the artifact for a change it does not carry.
#
# "Never reaches an image" is the test, not "is not source": DERIVERS
# above are also copied into nothing and DO belong in their sets,
# because they decide what the image reports. The line between the two
# lists is whether the file has a say in the artifact's identity.
("agent", "agent/README.md"),
("agent", "agent/ruff.toml"),
("agent", "agent/docker-compose.yml"),
# vite builds from src/, index.html and public/; it never reads test/,
# so a frontend test change cannot reach `dist`.
("web", "frontend/test/gallery.spec.js"),
],
)
def test_files_that_never_reach_an_image_do_not_version_it(artifact, path):
paths = declared_paths(artifact)
excluded = [p[len(":(exclude)"):] for p in paths if p.startswith(":(exclude)")]
inc = [p for p in paths if not p.startswith(":(exclude)")]
included = any(covered_by(path, i) for i in inc)
exempted = any(covered_by(path, e.rstrip("*").rstrip("/")) for e in excluded)
assert not included or exempted, (
f"{path} is in the {artifact} path set but is not copied into the "
f"image — it would re-version and rebuild {artifact} for a change it "
f"does not ship."
)
+60
View File
@@ -9,3 +9,63 @@ async def test_health_returns_ok(client):
assert response.status_code == 200
body = await response.get_json()
assert body == {"status": "ok"}
# --- build identity (milestone 318 step 6) --------------------------------
#
# With no version image tags left, /api/health is the only place an instance
# says which build it is. Both fields are OMITTED when unset rather than sent
# empty: absence already means "cannot say" — an image predating the field
# says exactly that by not having the key — and a second spelling would make
# every reader special-case it (note #3127 §7).
#
# The test above is load-bearing for that: it asserts the body is EXACTLY
# {"status": "ok"} when nothing is stamped, so a well-meaning `or ""` default
# fails it.
@pytest.mark.asyncio
async def test_health_reports_the_build_it_is(client, monkeypatch):
from backend.app.api import health
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
monkeypatch.setattr(health, "FC_CHANNEL", "dev")
body = await (await client.get("/api/health")).get_json()
assert body == {
"status": "ok",
"version": "2026.08.28.1249",
"channel": "dev",
}
@pytest.mark.asyncio
async def test_health_keeps_the_channel_out_of_the_version(client, monkeypatch):
"""Rule 149, asserted rather than assumed.
The tempting shortcut is a `-dev` suffix on the version. The extension's
comparator parses each dotted segment with `parseInt`, so a suffixed
segment reads as 0 and every dev build compares equal to every other —
#2993 exactly. Two separate keys cannot express that mistake.
"""
from backend.app.api import health
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
monkeypatch.setattr(health, "FC_CHANNEL", "dev")
body = await (await client.get("/api/health")).get_json()
assert body["version"] == "2026.08.28.1249"
assert "dev" not in body["version"]
@pytest.mark.asyncio
async def test_health_omits_a_channel_it_cannot_name(client, monkeypatch):
"""A locally-built image has a version but no channel. It must not gain an
empty one — the key's absence is the answer."""
from backend.app.api import health
monkeypatch.setattr(health, "FC_VERSION", "2026.08.28.1249")
monkeypatch.setattr(health, "FC_CHANNEL", "")
body = await (await client.get("/api/health")).get_json()
assert body == {"status": "ok", "version": "2026.08.28.1249"}
+149
View File
@@ -0,0 +1,149 @@
"""What the release changelog promises, and the way it would lie quietly.
A changelog has no consumer that checks it. If it lists the wrong span nothing
fails — the release publishes, reads perfectly, and tells the operator that a
month of work landed in a build that never contained it. That is the same
silent-and-plausible failure class as a revision that identifies the wrong
content (`test_artifact_identity.py` guards the other side of it), so the span
selection is asserted rather than eyeballed.
Everything runs the script the way `release.yml` runs it — as a subprocess,
through `--dry-run`. That is the same code path as a real publish right up to
the HTTP call, so these exercise the interface CI uses instead of a Python
re-implementation of it.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parent.parent
SCRIPT = ROOT / "scripts" / "release_notes.py"
def notes(*args: str, cwd: Path | None = None) -> str:
return subprocess.run(
["python3", str(SCRIPT), "--dry-run", *args],
capture_output=True, text=True, check=True, cwd=cwd or ROOT,
).stdout
def body_of(out: str) -> str:
assert "--- body ---" in out, f"no body was rendered:\n{out}"
return out.split("--- body ---", 1)[1]
def git(repo: Path, *args: str) -> str:
return subprocess.run(
["git", "-c", "user.email=ci@example.invalid", "-c", "user.name=ci",
"-c", "commit.gpgsign=false", *args],
capture_output=True, text=True, check=True, cwd=repo,
).stdout.strip()
@pytest.fixture
def shaped_history(tmp_path: Path) -> Path:
"""Three releases spanning the rule 148 tag-shape change.
Ancestry order is `v26.06.04.0` → `v2026.08.28.2208` → `v2026.08.29.1000`,
which is the exact arrangement where walking ancestry and sorting a list
disagree — see the test below. Synthetic rather than taken from this repo's
own tags so it holds whether or not CI's checkout brought the tags along:
a span test that quietly skips is the one outcome worse than a failing one.
"""
repo = tmp_path / "shaped"
repo.mkdir()
git(repo, "init", "-q", "-b", "main")
for i, tag in enumerate(("v26.06.04.0", "v2026.08.28.2208", "v2026.08.29.1000")):
(repo / "f.txt").write_text(f"{i}\n")
git(repo, "add", "f.txt")
git(repo, "commit", "-q", "-m", f"work landing in {tag}")
git(repo, "tag", tag)
# One more commit and a merge, so the merge-exclusion test has something to
# exclude that a first-parent listing would otherwise show.
git(repo, "checkout", "-q", "-b", "side")
(repo / "g.txt").write_text("side\n")
git(repo, "add", "g.txt")
git(repo, "commit", "-q", "-m", "feat: work done on the side branch")
git(repo, "checkout", "-q", "main")
git(repo, "merge", "-q", "--no-ff", "side", "-m", "Merge pull request #999 from side")
git(repo, "tag", "v2026.08.30.0900")
return repo
def test_the_previous_release_is_found_by_ancestry_not_by_sorting(shaped_history):
"""The trap this repo is standing in right now.
Rule 148 moved the tag shape from `v26.05.22.0` to `v2026.08.28.2208`.
Lexicographically `v2026...` sorts BEFORE `v26...` — the third character is
`0` against `6` — so a sorted-list implementation reaches back past every
new-shape tag to the newest OLD-shape one and emits months of commits as
"changes since". It looks entirely correct on any repo whose tags share a
single shape, which is every repo until the day the shape changes.
Here, ancestry says `v2026.08.28.2208` and sorting says `v26.06.04.0`.
"""
out = notes("v2026.08.29.1000", cwd=shaped_history)
assert "previous=v2026.08.28.2208" in out
assert "v26.06.04.0" not in out
def test_the_body_names_the_span_it_actually_listed(shaped_history):
"""A body whose heading says "since X" over commits computed from Y is
unfalsifiable from outside — both halves read fine on their own."""
body = body_of(notes("v2026.08.29.1000", cwd=shaped_history))
assert "## Changes since v2026.08.28.2208" in body
assert "v2026.08.28.2208..v2026.08.29.1000" in body
assert "work landing in v2026.08.29.1000" in body
assert "work landing in v2026.08.28.2208" not in body
def test_merges_are_excluded_so_the_list_is_the_work(shaped_history):
"""Rule 153 merges dev into main with a plain merge commit, so `main`'s
first-parent view is nothing but "Merge pull request #N". Including those
would publish a changelog of PR numbers over the actual changes."""
body = body_of(notes("v2026.08.30.0900", cwd=shaped_history))
assert "feat: work done on the side branch" in body
assert "Merge pull request #999" not in body
def test_the_first_release_still_renders_with_nothing_behind_it(shaped_history):
"""No previous tag is reachable from the oldest one. That is a real state,
not an error, and it must not take the release down with it."""
out = notes("v26.06.04.0", cwd=shaped_history)
assert "previous=<none>" in out
assert "## Changes" in body_of(out)
def test_a_non_tag_ref_renders_but_refuses_to_claim_it_published():
"""`--dry-run HEAD` is the operator's preview before deciding to cut a tag
at all. It must not describe itself as a release that happened."""
out = notes("HEAD")
assert "which is not a tag" in out
body_of(out)
def test_the_rollback_refs_name_all_three_images():
"""Rule 145: `:c-<sha>` is the rollback unit, and the three images move
together. A release listing only the web image sends an operator into a
rollback that leaves ml and agent on the newer build — the exact mismatch
build.yml builds all three on every push to avoid."""
body = body_of(notes("HEAD"))
for image in ("fabledcurator", "fabledcurator-ml", "fabledcurator-agent"):
assert f"bvandeusen/{image}:c-" in body, f"{image} missing from the rollback refs"
def test_an_unbounded_span_is_truncated_and_says_so():
"""With no reachable previous tag the span is the whole history. Emitting
eleven hundred lines would bury the one line explaining why there are
eleven hundred of them, so the cap is part of the message, not a silent
slice."""
out = notes("HEAD")
if "previous=<none>" not in out:
pytest.skip("a previous tag is reachable from HEAD in this checkout")
body = body_of(out)
listed = [ln for ln in body.split("\n") if ln.startswith("- ")]
assert len(listed) <= 200
assert "more than a changelog is for" in body