Milestone 271, complete — plus the review-pass batch that preceded it.
What changes for an operator
dev and main each build, sign and bundle their own extension. Trying an extension change no longer requires merging to main, which is what rules 146/147 name as testing by shipping.
One AMO signature per extension change, shared by both channels. The version is the commit time of the newest packaged-extension change (family rule 149 — never a commit count, which orders by branch rather than by recency), so both branches derive the same number for the same source. dev signed 1.0.3500147; this merge should find ext-1.0.3500147 already cached and bundle the byte-identical XPI into :latest with no second AMO call. Worth eyeballing sign-extension's cache step on the main build — that property is what makes two channels affordable, and this is its first cross-channel test.
Merge with a merge commit, not squash or rebase. Both rewrite the committer date, so main would derive a higher version than dev, miss the cache, and burn a second signature for identical code. Verified against existing history: origin/main and the dev commit it merged both derive 1.0.3465860, because the merge commit is TREESAME on the extension pathspec and so is never the newest listed commit.
The channel is reported beside the version, never inside it — /api/extension/manifest gains an optional "channel", and the popup, toolbar tooltip and Settings card name it. A -dev suffix would collapse under the comparator's parseInt, making every dev build compare equal.
Contents
Milestone 271 — extension channels
cd5444e derive the version from commit time, not commit count (#3092)
239b1ed build :dev images again so the dev channel can carry a build
5447a40 the derived version drives signing (step 4)
9eb946b sign on dev too, bundle the XPI into :dev (step 6)
fe48e77 retire the manual-bump guard, true up the docs (step 5)
a7e626a report the channel beside the version (step 7)
0db38cc log in to the registry with the docker CLI, not docker/login-action (#3118)
Review pass (#3066–#3072)
39cf81a clear an artist's attachments before the cascade delete
2ce467e artist cascade preview counts posts and attachments
2e0f8f8 reclaim orphaned attachments — rows and store blobs
ddf8960516521e retire deviantart end-to-end
8915547 cover the Layer-2 auto-refetch remediation
bfc5135 true up README
1ac448d5a0e1bb four small cleanups + batch the auto-apply sweeps' image_tag inserts
Verification
CI green on 0db38cc for lint, extension-version, backend unit, frontend build, integration, and the extension lint/vitest/XPI-content lane.
One caveat, carried knowingly: build-agent went red on that run after its push completed — all layers pushed (#14 DONE 179.9s) and the registry shows fabledcurator-agent:dev written at 16:19:44, then buildx failed resolving the tag in the local daemon. The artifact is correct; the lane is not. Filed alongside #3118, which is the same family of problem: concurrent jobs sharing per-runner state.
Known follow-ups
#3114 — the docker layer cache is per-runner, so it is a coin flip: build-agent alternated 7s and 10m13s on byte-identical source. Registry-backed cache is the fix, and it is the operator's next piece of work.
#3117 — two concurrent builds deriving the same unsigned version both call AMO; the loser gets a 409. Fails loudly, cleans up after itself.
Milestone 271, complete — plus the review-pass batch that preceded it.
## What changes for an operator
`dev` and `main` each build, sign and bundle their own extension. Trying an extension change no longer requires merging to main, which is what rules 146/147 name as testing by shipping.
**One AMO signature per extension change, shared by both channels.** The version is the commit *time* of the newest packaged-extension change (family rule 149 — never a commit count, which orders by branch rather than by recency), so both branches derive the same number for the same source. `dev` signed `1.0.3500147`; this merge should find `ext-1.0.3500147` already cached and bundle the byte-identical XPI into `:latest` with **no second AMO call**. Worth eyeballing `sign-extension`'s cache step on the main build — that property is what makes two channels affordable, and this is its first cross-channel test.
**Merge with a merge commit, not squash or rebase.** Both rewrite the committer date, so main would derive a *higher* version than dev, miss the cache, and burn a second signature for identical code. Verified against existing history: `origin/main` and the dev commit it merged both derive `1.0.3465860`, because the merge commit is TREESAME on the extension pathspec and so is never the newest listed commit.
The channel is reported *beside* the version, never inside it — `/api/extension/manifest` gains an optional `"channel"`, and the popup, toolbar tooltip and Settings card name it. A `-dev` suffix would collapse under the comparator's `parseInt`, making every dev build compare equal.
## Contents
**Milestone 271 — extension channels**
- `cd5444e` derive the version from commit time, not commit count (#3092)
- `239b1ed` build `:dev` images again so the dev channel can carry a build
- `5447a40` the derived version drives signing (step 4)
- `9eb946b` sign on dev too, bundle the XPI into `:dev` (step 6)
- `fe48e77` retire the manual-bump guard, true up the docs (step 5)
- `a7e626a` report the channel beside the version (step 7)
- `0db38cc` log in to the registry with the docker CLI, not `docker/login-action` (#3118)
**Review pass (#3066–#3072)**
- `39cf81a` clear an artist's attachments before the cascade delete
- `2ce467e` artist cascade preview counts posts and attachments
- `2e0f8f8` reclaim orphaned attachments — rows and store blobs
- `ddf8960` `516521e` retire deviantart end-to-end
- `8915547` cover the Layer-2 auto-refetch remediation
- `bfc5135` true up README
- `1ac448d` `5a0e1bb` four small cleanups + batch the auto-apply sweeps' image_tag inserts
## Verification
CI green on `0db38cc` for lint, extension-version, backend unit, frontend build, integration, and the extension lint/vitest/XPI-content lane.
One caveat, carried knowingly: `build-agent` went red on that run **after** its push completed — all layers pushed (`#14 DONE 179.9s`) and the registry shows `fabledcurator-agent:dev` written at 16:19:44, then buildx failed resolving the tag in the local daemon. The artifact is correct; the lane is not. Filed alongside #3118, which is the same family of problem: concurrent jobs sharing per-runner state.
## Known follow-ups
- **#3114** — the docker layer cache is per-runner, so it is a coin flip: `build-agent` alternated 7s and 10m13s on byte-identical source. Registry-backed cache is the fix, and it is the operator's next piece of work.
- **#3117** — two concurrent builds deriving the same unsigned version both call AMO; the loser gets a 409. Fails loudly, cleans up after itself.
- **#3108**, **#3109**, **#3093** — obsidian literal sweep, test-factory canon, JS↔Py artist-pattern mirror.
`delete_artist_cascade` could abort partway through, and it aborted after
the irreversible half. Deleting an artist CASCADEs to Post (post.artist_id
is ondelete=CASCADE), which SET NULLs post_attachment.post_id — and
`uq_post_attachment_null_post_sha` is a partial UNIQUE on sha256 ALONE
WHERE post_id IS NULL. So any two of that artist's attachments sharing a
sha collapse onto one another and raise.
That shape is ordinary, not corrupt: `_capture_attachment` deliberately
writes one row per post over a single sha-addressed blob, so a creator who
attaches the same pdf to two posts already has two such rows. A
pre-existing filesystem-import row (post_id NULL) with the same sha
collides on its own.
The images and their on-disk files are deleted and committed in 500-row
batches BEFORE the artist row is touched, so the failure landed after
them: images gone, artist and posts alive, files unrecoverable.
Fix: delete the artist's post_attachment rows explicitly first, matched by
artist_id OR by the owning post's artist (artist_id is nullable, so neither
arm alone covers every row). `_repoint_post_links` already guards the
identical collision class in the reconcile path; this is its artist-cascade
counterpart. Migration 0043 reasoned only about upgrade-time safety and
never about this later SET NULL.
The sha-addressed blobs are deliberately left on disk: one blob backs many
rows, so unlinking needs a refcount pass, and this Tier-C op must not
delete bytes its own preview never disclosed.
Adds `attachments_deleted` to the summary, and two regression tests — the
same sha on two posts, and an unrelated NULL-post row that must survive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`project_artist_cascade` is documented as "a read-only projection of what
delete_artist_cascade would touch" and drives the Tier-C confirm dialog,
but it counted only images, sources, thumbs, import_tasks and bytes. It
never counted posts or attachments, both of which the apply destroys.
That is silent in the worst case. `Post.artist_id` is ondelete=CASCADE, so
every post goes whether or not it carried an image — and FC has a large
body-only post population (#1288 measured 694 pixiv posts with text and no
images). Such an artist previewed as `images: 0`, reading as "empty, safe
to remove", while the apply destroyed every captured body, description,
external-link set and raw_metadata snapshot. The danger-zone card already
promised "every image, source, post, and attachment" — the copy was honest
and the numbers were not.
Root of the drift: the preview re-derived its own predicates instead of
sharing the apply's, the same shape as the 2026-06-08 fandom-tag deletion
that rule 93 exists for. So rather than bolt on two counts, both halves now
build from shared `_artist_{images,posts,attachments}_conditions` helpers,
following the `_unused_tag_conditions` / `_bare_post_conditions` style
already in the file. Sources keep no helper — the apply doesn't query them
either, it gets them from the Artist.sources ORM cascade.
The apply also now reports `posts_deleted` (counted before the delete,
since the CASCADE leaves nothing to count after). Rule 93's second half
asks for the apply to be tested, and parity is only assertable if both
halves state the number.
Adds a preview/apply parity test that runs both against one artist and
asserts the three pairs agree AND that the rows actually went, plus a
body-only-artist test covering the case that motivated this.
The confirm dialog's counts grid renders every key, so posts and
attachments surface there automatically; the prose summary line names
posts explicitly, since that is the number that changes how an artist
reads.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PostAttachment's two FKs are both ON DELETE SET NULL, so a deleted post or
artist left the row behind rather than taking it. Nothing ever pruned those
rows, and nothing in the repo had ever unlinked a file under the attachment
store — so both rows and bytes accumulated permanently, invisible to every
existing diagnostic.
Why a disk->DB reconciliation rather than a row sweep: the store is
sha-addressed and idempotent, so ONE blob backs MANY rows. Deleting a row
does not free its blob, and since the artist cascade (#3066) now deletes
its attachment rows outright, a freed blob has no DB pointer left to find
it by. Walking the store and asking "does any row still reference this
sha?" catches orphans from every cause, including ones no future delete
path will think to report.
Preview and apply share `_orphan_attachment_conditions` (rule 93). The
dry-run derives its surviving-sha set by NEGATING that same predicate, so
it is honest about blobs the delete would free rather than counting them as
still-referenced — the one place this was easy to get backwards, so it has
its own parity test.
Guards, each with a reason:
- A blob is written before its row commits, so a just-stored file legitimately
has no referencing row. Files under 6h are never judged — same guard and
reasoning as ORPHAN_TEMP_MIN_AGE_HOURS.
- `.partial` staging files belong to cleanup_orphaned_temp_files; skipped
rather than raced.
- The sha is parsed as the first 64 chars, not via Path.stem: store() takes
the extension from the source filename, and a URL-encoded basename yields a
multi-dot suffix that would make stem eat part of the sha.
- A 900s walk budget reports partial=True instead of running to the task's
hard limit (rule 89).
- TASK_STUCK_THRESHOLD_MINUTES override at 30 (= time_limit 25 + 5). Without
it a healthy 20-minute walk is phantom-flagged 'RecoverySweep' at the bare
5-min default — the #883 failure class; its invariant test is mirrored here.
Defaults to the safe preview at both the task and the route, unlike the other
maintenance triggers: this apply unlinks files. Operator-triggered only,
never on a beat.
Ships with its UI (rule 27): AttachmentReclaimCard in Cleanup → Duplicates &
leftovers, built on the existing useMaintenanceTask/MaintenanceTile shapes, so
a run survives navigating away. Surfaces files_failed and partial explicitly,
since both change what the numbers mean.
Also promotes humanBytes to utils/bytes.js — it was byte-identical in
VideoDedupCard and GatedPurgeCard and this card would have been the third
copy. The three divergent `formatBytes` helpers are deliberately left alone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Executes the 2026-07-05 product decision (FC downloaders = art-dedicated
services only), which removed Twitter/X and Bluesky but left deviantart
fully wired for seven weeks — the half-retired state rule 22 exists to
prevent.
Removed: the PlatformInfo module and its registry entry, the gallery-dl
extractor block, extension_service's artist-page pattern, the extension's
PLATFORMS + PLATFORM_ARTIST_PATTERNS entries, its manifest host permission
and content-script match, the frontend icon/colour/label, and the operator-
facing "supported platforms" list that still advertised it.
Two judgment calls, both recorded in migration 0088:
* existing `source` rows are DISABLED, not deleted. The row is the only
record of the artist's DeviantArt URL. Disabling is also required for
correctness rather than tidiness: with the platform unregistered the
download path falls through to gallery-dl, which carries its OWN
deviantart extractor, so an enabled row would have kept downloading
from a dropped platform.
* the `credential` row IS deleted — a live session cookie for a site FC
will never call again.
Adds the invariant whose absence is why manifest.json drifted in the first
place: nothing tied its domain lists back to the platform table. The
extension suite now asserts both directions, plus that no host permission
belongs to an unclaimed domain (`*://*/*` exempted — FC is self-hosted at
an operator-chosen URL the extension cannot enumerate).
Extension version 1.0.10 -> 1.0.11: ci.yml's guard hard-fails a packaged
extension change without a bump. No release is cut — build.yml's
sign-extension job only runs on main.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Operator confirms the instance has never used DeviantArt, so there is
nothing for 0088 to quiesce. The migration only ever had two jobs —
disable leftover `source` rows and delete a stale `credential` row — and
both were guards against data that does not exist here.
Removing it rather than keeping a no-op: a migration that runs on every
deploy to touch zero rows is a permanent cost paid for a hypothetical,
and it would read to a future reader as evidence that DeviantArt sources
once existed. `platform` has no CHECK constraint, so retiring the key
needs no schema change of its own.
alembic head returns to 0087.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refetch_service was the only module under backend/app/services/ with no
test file — and not an inert one: it runs unattended off the recovery
sweep and deletes a file from disk before asking a downloader to replace
it. The frontend cites it by name as the reason the Import tab could be
retired ("imports heal themselves").
The ticket described it as having zero direct coverage. That is true of
the module, but not of the code: test_api_import_admin.py already drives
the happy path end-to-end through the refetch route — file deleted, task
flagged, one dispatch, second attempt a no-op. These 17 tests therefore
target what the route tests cannot reach rather than restating them:
* every branch of resolve_refetch_source — disabled Source, a
`sidecar:<platform>:<slug>` synthetic anchor, a platform mismatch,
the gallery-dl `NN_` numbering-prefix sidecar, the lowest-id pick
among several candidates, and each of the five ways it declines
(no sidecar, unreadable JSON, non-object JSON, no platform, no
artist folder / no matching Artist row).
* that the file SURVIVES when nothing re-pollable resolves. This is
the assertion the module exists for: `no_source` is the common case
on a filesystem-only library, where the file on disk is the
operator's only copy. The route-level no_source test cannot catch a
regression here — its path never existed, so an unconditional unlink
would pass it.
* that the `refetched` bound is checked BEFORE the unlink, so a second
sweep leaves the re-downloaded file alone rather than deleting it
again.
* that an unlink failure is logged and stepped over, not raised —
a raise would abort the whole sweep for every other poison-pill row
in the batch. Exercised with a real IsADirectoryError rather than a
patched pathlib.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
README.md was last touched in 4aff9c5 (2026-05-14) and several of its
most visible lines had gone false:
- "Pre-v1. Not yet functional." — FC has been continuously deployed for
months. Replaced with what main/dev actually mean for what's running.
- "Node 22 pre-installed" — ci-requirements.md and extension.yml both say
node 24, and frontend/package.json requires >=24.
- "Runner label python-ci — a runner with Python 3.14, ruff and Node 22
pre-installed ... The runner image (runner-base:python-ci) is built
from CI-Runner/CI-python/" — describes runs-on as selecting the
toolchain. It doesn't: runs-on is a scheduling label, and every job
names its own container.image (ci-python:3.14, or node:24-bookworm-slim
for the extension lane).
- "Both ci.yml and build.yml use this label" — there are three workflows.
The CI section now points at ci-requirements.md rather than restating
it, so the two can't drift apart again; that file is current and is the
one the CI-runner process expects.
Added a "What's in here" table for the five deployable pieces — the
extension, the GPU agent and the ML image were unmentioned, three of the
five. Also corrected the RELEASE_TOKEN write:release scope, which is no
longer "for future release-cutting workflows": it backs the ext-<version>
releases that cache the signed XPI.
Refs #3070
Items 2-5 of #3072. Item 1 (the per-row sweep inserts) is separate.
2. .fc-bad was not merely duplicated — it is .fc-weak under a second
name. Both local definitions were `color: rgb(var(--v-theme-error))`,
identical to the global .fc-weak, and GpuAgentCard was already using
.fc-weak to colour exactly what GpuActivityPanel coloured .fc-bad (an
errored count, red when non-zero). So rather than promoting a synonym
to app.css, both call sites now use .fc-weak and the local defs are
gone. app.css's status-colour comment records why there is no .fc-bad,
next to the existing note on why .fc-ok is deliberately NOT global.
3. GalleryItem.vue's obsidian literals now use --v-theme-background,
which IS obsidian (vuetify-theme.js maps background -> surfaces.
obsidian). Preferred over --fc-chrome-rgb: same value, but that
variable is named for the nav fade, not for the palette entry.
The ticket said these were the only three real uses in the tree. They
are not — GalleryItem itself had two more in the artist-label
gradient (fixed here, so the file is now consistent), and ~13 more
live in SeriesView, SeriesReaderView, ImageViewer, ArtistHeader,
ExploreView and GalleryFilterBar. Those are a separate sweep, filed
rather than folded in here.
4. The attachment download path had two hand-formatted copies. One
definition now, `attachment_download_url`, next to the model both
serializers already import. The test pins it by MATCHING the built
path against the app's real URL map rather than comparing to a
literal — a string-equality test would still pass after someone
renamed the route, which is the drift the helper exists to prevent.
5. Extension API key now compares with hmac.compare_digest. Compared as
BYTES, not str: compare_digest's str form raises TypeError on
non-ASCII, and this value comes straight from an attacker-controlled
header, so the str form would turn a junk key into a 500 instead of a
403. Low stakes either way — the API is unauthenticated-by-design on
a LAN — but it costs nothing.
Refs #3072
Item 1 of #3072. Both sweeps issued a single-row pg_insert(image_tag)
from inside their per-image loop. Steady state that is nothing; a first
sweep over a back-catalogue is one round-trip per applied tag, tens of
thousands of them. Each chunk now collects its rows and writes them in
one statement.
The ticket suggested one insert per chunk PER TAG. A single multi-row
VALUES carries every tag at once, so it is one statement per chunk full
stop — and the sweeps already accumulate across all heads before they
commit, so nothing had to be restructured to allow it.
Not a new helper: wip_title.apply_wip_image_tags was already doing the
chunked ON CONFLICT DO NOTHING insert, so that shape is extracted to
services/image_tag_apply.insert_image_tags and all three writers share
it. The extraction deliberately leaves wip_title's pre-SELECT behind
rather than pulling it into the shared function — the sweeps don't need
it (their `skip` sets already exclude applied and rejected images) and
it exists only to produce an accurate count, which the sweeps also
compute themselves. So the shared primitive returns nothing: psycopg
reports rowcount -1 for a multi-row ON CONFLICT DO NOTHING insert, and a
count taken from the statement would be a lie rather than an
approximation.
Ordering note for the system-tag sweep: tag rows are now written after
that chunk's PresentationReview rows rather than interleaved before
them. Safe — PresentationReview FKs to image_record and tag, not to
image_tag.
Chunk size stays 5000: 5000 rows x 3 bound params = 15000, inside
Postgres' 65535-parameter ceiling with room to spare.
tests/test_image_tag_apply.py covers the primitive directly, since it is
now the single place three writers can be wrong at once — most
importantly that a re-run never restamps a hand-applied tag's source,
which would silently poison head training (it excludes the auto
sources).
Left alone: _insert_presentation_review is still per-row, and the
retract path still deletes per-row. Both operate on sets that are small
by construction, unlike the apply path.
Refs #3072
Rule 149: an artifact's ordering key must be time-derived, never a commit
count. packaging.sh's cmd_patch was a count.
Why that matters here rather than in the abstract. A count is per-branch:
dev and main count different histories of the SAME code. Today only main
signs, so nothing has ordered the two against each other and the fault is
invisible. The moment dev also publishes an extension, the two versions
order by which branch accumulated more commits rather than by which is
newer — and a squash-merge makes it permanent, because main gains one
commit where dev gained five. dev then climbs away from main and a dev
install can never cross back.
That is Roundtable's 2026-08-24 incident (Scribe #2993) in a different
repo: their versionCode was the branch's commit count, and it produced a
channel you could enter and not leave. Measured on this repo today the
old formula gives main=23, dev=24 — one apart, which is exactly how the
inversion stays invisible until it strands somebody.
New formula: minutes since 2020-01-01 of the LATEST commit touching a
packaged extension file. Same anchor and unit Roundtable settled on.
Commit time, not build time, and the difference is load-bearing:
- stable while the extension is unchanged, so the ext-<version>
signature cache still hits and AMO is called once per extension
CHANGE rather than once per push. Build-time minutes would re-sign
on every push and never let two channels share a signature.
- after a merge, main sees the same commit and derives the same
number, so :latest reuses the signature :dev already produced for
byte-identical code. Same code, same version, one signing.
- monotonic: max() over a set that only gains members. Verified
across all 24 extension-touching commits, zero non-monotonic steps.
- reproducible from any checkout.
Derives 1.0.3499884 on dev, 1.0.3465860 on main — both far above the
last hand-set 1.0.11, so milestone 271's backfill guard is satisfied by
construction rather than by an offset.
Still shadow-only: nothing reads the derived value yet. Both shadow
steps log it, and ci.yml's runs on dev too, so both channels' numbers
are visible — that is the pair that has to stay ordered. Prior shadow
observations describe the OLD formula and prove nothing about this one,
so the window restarts; ci.yml says so at the step.
New requirement recorded in ci-requirements.md: a depth-1 clone derives
a wrong, too-low value rather than failing, so fetch-depth: 0 is
load-bearing wherever packaging.sh version is called.
Refs #3092, milestone 271
build.yml triggered on main and tags only. The 2026-05-26 comment gave
the reason: "operator tests from :latest after merge-to-main, not from
the dev branch image. Saves one full docker build per dev push."
That trade has since been named as a fault. Family rule 147 — main IS
production, test on :dev, never by shipping — and rule 146 — a rolling
channel refreshes itself, and a channel that can only be refreshed by
shipping is not a channel. 146's note on 147 describes this exact shape:
the pressure to test by shipping does not come from carelessness, it
comes from :dev being unable to carry the build.
Two live consequences, not hypotheticals:
- docker-compose.yml pins fabledcurator:dev, an image nothing has
published since May. The registry-image path of the documented
quick-start could not have worked.
- trying an extension change required merging to main, because
sign-extension is gated to main and :dev did not exist to carry an
XPI. Shipping was the only way to test.
All three images build on dev. Deliberate: a :dev web image paired with
a stale :dev ml or agent is a worse trap than no dev channel, because
the mismatch surfaces as a runtime failure rather than a missing tag.
The cost the 2026-05-26 note was avoiding is real and is now paid on
every dev push — layer reuse should keep ml's cost to the COPY layers,
but if it bites, narrowing is a `paths:` filter away.
:dev only. The dev path never writes :c-<sha>: that is the rollback unit
(rule 145), and a rolling tag may legitimately carry newer contents than
the :c-<sha> of the same commit.
This does NOT yet put an XPI on :dev — sign-extension is still gated to
main, and ungating it has to wait for the derived version to control
publishing, or dev would sign the hand-set 1.0.11, hit the existing
cache and ship main's stale XPI. That is the next step.
Cutover. sign-extension no longer reads the version out of the repo — it
runs packaging.sh version and stamps the result into manifest.json and
package.json in the working tree before web-ext sees them. Never
committed back: the commit carrying the bump would itself be a change to
the extension and would move the version again.
Shadow mode ends here, in both build.yml and ci.yml. It had one job —
validate the formula at zero cost before a real AMO version was burned —
and CI confirmed it on 239b1ed: shadow: manual=1.0.11 derived=1.0.3499884.
build-web re-derives rather than being handed the value, so it gains
fetch-depth: 0. It was the outstanding landmine: a depth-1 clone derives a
WRONG, too-low version rather than failing, and would then 404 fetching a
release that exists under its real name. sign-extension and
extension-version already had full history.
New guard, and it stays permanently: refuse to sign when the derived
version is strictly OLDER than the highest ext-* release already signed.
Firefox rejects a downgrade and AMO never releases a burned version, so
backwards is unrecoverable — it strands every install that took the higher
one. Strictly older, not older-or-equal: equality is the ordinary case,
an unchanged extension deriving the same version it did last build, which
is exactly what makes the ext-<version> cache hit and holds AMO to one
call per extension CHANGE rather than per push. The release list is
paginated because ext-* shares it with the v* tags, and the bound fails
rather than calling the highest it happened to see the highest there is.
First derived value is 1.0.3499884 against a highest-signed ext-1.0.10, so
the backfill direction is right by six orders of magnitude. 1.0.11 sits in
the repo and was never signed; nothing is stranded by skipping past it.
Still main-only. Step 6 ungates sign-extension to dev, which is what
actually puts an XPI on :dev.
Note for step 5: ci.yml's manual-bump guard is now false. It still demands
a hand bump when a packaged file changes, and that bump no longer decides
anything — the derived value overwrites it at build time. Harmless but
pointless, and it should be retired before the next extension change.
The step the milestone exists for. sign-extension ungates from main-only
to main-or-dev, and build-web downloads the XPI on dev as well, so a dev
push produces an image carrying the extension that is being developed
rather than requiring a merge to try one.
Not two signatures. The version is the commit TIME of the newest packaged
extension change, so dev and main derive the SAME number for the same
source. A dev push that changes the extension signs it; the merge to main
finds the ext-<version> release already there, hits the cache, and bundles
the byte-identical XPI into :latest with no second AMO call. One signature
per extension CHANGE, shared by both channels. That property is what makes
two channels affordable at all, and it is why step 4 had to land first:
ungating this while the version was still the hand-set 1.0.11 would have
found the existing ext-1.0.11 release, skipped AMO, and bundled main's
stale XPI into :dev — a dev channel confidently serving old code.
Tags stay excluded. The tag path deliberately skips signing and polls for
the release instead (the 2026-05-27 race).
The ext-<version> release's target_commitish moves from the literal "main"
to $GITHUB_SHA. Either branch can create that release now, and tagging a
dev-signed XPI against a main commit that need not even contain the source
it was built from is a lie that costs nothing to avoid.
Known, not addressed here: two concurrent builds that both derive the same
unsigned version will both call AMO and the loser gets a 409. The window
already existed between main and tag pushes; dev signing widens it. It
fails loudly rather than shipping anything wrong, and the rollback trap
cleans up the empty release. Filed separately.
Also unchanged here: ci.yml's manual-bump guard is still in place and
still false. It does not fire on this commit — nothing packaged changed —
but it will fail the lane on the next extension change, demanding a bump
that no longer decides anything. Step 5 next.
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Milestone 271, complete — plus the review-pass batch that preceded it.
What changes for an operator
devandmaineach build, sign and bundle their own extension. Trying an extension change no longer requires merging to main, which is what rules 146/147 name as testing by shipping.One AMO signature per extension change, shared by both channels. The version is the commit time of the newest packaged-extension change (family rule 149 — never a commit count, which orders by branch rather than by recency), so both branches derive the same number for the same source.
devsigned1.0.3500147; this merge should findext-1.0.3500147already cached and bundle the byte-identical XPI into:latestwith no second AMO call. Worth eyeballingsign-extension's cache step on the main build — that property is what makes two channels affordable, and this is its first cross-channel test.Merge with a merge commit, not squash or rebase. Both rewrite the committer date, so main would derive a higher version than dev, miss the cache, and burn a second signature for identical code. Verified against existing history:
origin/mainand the dev commit it merged both derive1.0.3465860, because the merge commit is TREESAME on the extension pathspec and so is never the newest listed commit.The channel is reported beside the version, never inside it —
/api/extension/manifestgains an optional"channel", and the popup, toolbar tooltip and Settings card name it. A-devsuffix would collapse under the comparator'sparseInt, making every dev build compare equal.Contents
Milestone 271 — extension channels
cd5444ederive the version from commit time, not commit count (#3092)239b1edbuild:devimages again so the dev channel can carry a build5447a40the derived version drives signing (step 4)9eb946bsign on dev too, bundle the XPI into:dev(step 6)fe48e77retire the manual-bump guard, true up the docs (step 5)a7e626areport the channel beside the version (step 7)0db38cclog in to the registry with the docker CLI, notdocker/login-action(#3118)Review pass (#3066–#3072)
39cf81aclear an artist's attachments before the cascade delete2ce467eartist cascade preview counts posts and attachments2e0f8f8reclaim orphaned attachments — rows and store blobsddf8960516521eretire deviantart end-to-end8915547cover the Layer-2 auto-refetch remediationbfc5135true up README1ac448d5a0e1bbfour small cleanups + batch the auto-apply sweeps' image_tag insertsVerification
CI green on
0db38ccfor lint, extension-version, backend unit, frontend build, integration, and the extension lint/vitest/XPI-content lane.One caveat, carried knowingly:
build-agentwent red on that run after its push completed — all layers pushed (#14 DONE 179.9s) and the registry showsfabledcurator-agent:devwritten at 16:19:44, then buildx failed resolving the tag in the local daemon. The artifact is correct; the lane is not. Filed alongside #3118, which is the same family of problem: concurrent jobs sharing per-runner state.Known follow-ups
build-agentalternated 7s and 10m13s on byte-identical source. Registry-backed cache is the fix, and it is the operator's next piece of work.Executes the 2026-07-05 product decision (FC downloaders = art-dedicated services only), which removed Twitter/X and Bluesky but left deviantart fully wired for seven weeks — the half-retired state rule 22 exists to prevent. Removed: the PlatformInfo module and its registry entry, the gallery-dl extractor block, extension_service's artist-page pattern, the extension's PLATFORMS + PLATFORM_ARTIST_PATTERNS entries, its manifest host permission and content-script match, the frontend icon/colour/label, and the operator- facing "supported platforms" list that still advertised it. Two judgment calls, both recorded in migration 0088: * existing `source` rows are DISABLED, not deleted. The row is the only record of the artist's DeviantArt URL. Disabling is also required for correctness rather than tidiness: with the platform unregistered the download path falls through to gallery-dl, which carries its OWN deviantart extractor, so an enabled row would have kept downloading from a dropped platform. * the `credential` row IS deleted — a live session cookie for a site FC will never call again. Adds the invariant whose absence is why manifest.json drifted in the first place: nothing tied its domain lists back to the platform table. The extension suite now asserts both directions, plus that no host permission belongs to an unclaimed domain (`*://*/*` exempted — FC is self-hosted at an operator-chosen URL the extension cannot enumerate). Extension version 1.0.10 -> 1.0.11: ci.yml's guard hard-fails a packaged extension change without a bump. No release is cut — build.yml's sign-extension job only runs on main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>refetch_service was the only module under backend/app/services/ with no test file — and not an inert one: it runs unattended off the recovery sweep and deletes a file from disk before asking a downloader to replace it. The frontend cites it by name as the reason the Import tab could be retired ("imports heal themselves"). The ticket described it as having zero direct coverage. That is true of the module, but not of the code: test_api_import_admin.py already drives the happy path end-to-end through the refetch route — file deleted, task flagged, one dispatch, second attempt a no-op. These 17 tests therefore target what the route tests cannot reach rather than restating them: * every branch of resolve_refetch_source — disabled Source, a `sidecar:<platform>:<slug>` synthetic anchor, a platform mismatch, the gallery-dl `NN_` numbering-prefix sidecar, the lowest-id pick among several candidates, and each of the five ways it declines (no sidecar, unreadable JSON, non-object JSON, no platform, no artist folder / no matching Artist row). * that the file SURVIVES when nothing re-pollable resolves. This is the assertion the module exists for: `no_source` is the common case on a filesystem-only library, where the file on disk is the operator's only copy. The route-level no_source test cannot catch a regression here — its path never existed, so an unconditional unlink would pass it. * that the `refetched` bound is checked BEFORE the unlink, so a second sweep leaves the re-downloaded file alone rather than deleting it again. * that an unlink failure is logged and stepped over, not raised — a raise would abort the whole sweep for every other poison-pill row in the batch. Exercised with a real IsADirectoryError rather than a patched pathlib. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>build.yml triggered on main and tags only. The 2026-05-26 comment gave the reason: "operator tests from :latest after merge-to-main, not from the dev branch image. Saves one full docker build per dev push." That trade has since been named as a fault. Family rule 147 — main IS production, test on :dev, never by shipping — and rule 146 — a rolling channel refreshes itself, and a channel that can only be refreshed by shipping is not a channel. 146's note on 147 describes this exact shape: the pressure to test by shipping does not come from carelessness, it comes from :dev being unable to carry the build. Two live consequences, not hypotheticals: - docker-compose.yml pins fabledcurator:dev, an image nothing has published since May. The registry-image path of the documented quick-start could not have worked. - trying an extension change required merging to main, because sign-extension is gated to main and :dev did not exist to carry an XPI. Shipping was the only way to test. All three images build on dev. Deliberate: a :dev web image paired with a stale :dev ml or agent is a worse trap than no dev channel, because the mismatch surfaces as a runtime failure rather than a missing tag. The cost the 2026-05-26 note was avoiding is real and is now paid on every dev push — layer reuse should keep ml's cost to the COPY layers, but if it bites, narrowing is a `paths:` filter away. :dev only. The dev path never writes :c-<sha>: that is the rollback unit (rule 145), and a rolling tag may legitimately carry newer contents than the :c-<sha> of the same commit. This does NOT yet put an XPI on :dev — sign-extension is still gated to main, and ungating it has to wait for the derived version to control publishing, or dev would sign the hand-set 1.0.11, hit the existing cache and ship main's stale XPI. That is the next step.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.