Files
FabledCurator/extension
bvandeusenandClaude Opus 5 274f7ffe21
CI and images / lint (push) Successful in 3s
CI and images / extension-version (push) Successful in 3s
extension / lint (push) Successful in 21s
CI and images / frontend-build (push) Successful in 21s
CI and images / backend-lint-and-test (push) Successful in 31s
CI and images / integration (push) Successful in 2m10s
CI and images / sign-extension (push) Successful in 3s
CI and images / build-agent (push) Successful in 6s
CI and images / build-web (push) Successful in 1m51s
CI and images / smoke-web (push) Successful in 57s
CI and images / promote (push) Skipped
ci: the tests gate the publish — ci.yml folds into build.yml
Operator, 2026-09-23: *"tighten the gate so :dev can't publish on red tests"*,
then *"I don't want failing builds to publish anywhere going forward."*

Run 7348 is the worked example. The backend unit lane went red on `2f8f0bc`
and `build-web` pushed `:dev` in the same minute, because the lanes and the
build were SEPARATE WORKFLOWS on the same push trigger. Neither could see the
other's verdict. `:dev` was a "it built" signal, never a "it passed" one, and
nothing about that was visible from either run.

Two workflows cannot express the gate. A `needs:` edge only exists inside one
graph. So `ci.yml`'s five lanes move into `build.yml` and `ci.yml` is deleted;
`sign-extension`, `build-web` and `build-agent` now need all five.

Nothing here is a new mechanism — it is the same edge that has gated `promote`
since milestone 362 step 4, and it keeps that step's hardest-won property:
**not running is not the same as passing.** `needs` treats a SKIPPED
dependency as unsatisfied, so a lane that silently skips itself blocks the
publish exactly as a failing one does. Run 5290 is why that is worth stating.

Scope, said plainly rather than implied:

- Gated: every image tag (`:dev`, `:latest`, `:c-<sha>`), the weekly base
  refresh, and the `ext-<version>` signed-XPI release asset — `sign-extension`
  publishes too, so it is gated with the rest.
- Not gated, deliberately: `extension.yml` publishes nothing, and
  `release.yml` runs on a `v*` tag, generates notes rather than an artifact,
  and its commit already went through main's gated build.
- `pull_request` (Renovate bumps into `dev`) comes across with the lanes. Its
  runs are the lanes and nothing else, via an `if:` on each publishing job
  rather than an inference from the `needs` chain.

The cost, accepted knowingly: this workflow queues per branch and never
cancels, so on two pushes in quick succession the second's lint feedback waits
out the first's build. A slower red beats a fast red that ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-23 11:15:42 -04:00
..

FabledCurator Firefox Extension

Self-hosted Firefox extension that pushes session cookies from supported platforms (Patreon, SubscribeStar, Hentai-Foundry, Discord) into FabledCurator, and lets you add a creator as a Source from their page in one click.

Install (operator)

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 → Preferences) and paste in the FC URL + extension API key shown on the same card.

Develop

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/

Smoke checklist (after every release that touches extension/**)

  • npm run lint passes
  • npm run start loads the extension in a clean Firefox profile
  • Options page accepts FC URL + key, indicator turns green
  • Cookie export: log into patreon.com, click Patreon card → "X cookies exported"
  • Discord token: open discord.com, click Discord card → "Token captured"
  • Add as source: visit patreon.com/, click floating button → toast
  • 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. build.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

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.