Compare commits

...
Author SHA1 Message Date
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 9eb946b21b ci(extension): sign on dev too, and bundle the XPI into :dev (step 6)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m40s
CI / integration (push) Successful in 3m55s
Build images / sign-extension (push) Successful in 4m43s
Build images / build-web (push) Successful in 2m11s
Build images / build-agent (push) Successful in 10m13s
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.
2026-08-27 10:56:58 -04:00
bvandeusen 5447a40e97 ci(extension): the derived version drives signing (milestone 271 step 4)
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 30s
extension / lint (push) Successful in 27s
Build images / sign-extension (push) Skipped
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
Build images / build-web (push) Successful in 2m8s
Build images / build-ml (push) Successful in 2m48s
CI / integration (push) Successful in 3m52s
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.
2026-08-27 10:45:17 -04:00
bvandeusen 239b1ed8d9 ci: build :dev images again so the dev channel can carry a build
Build images / sign-extension (push) Skipped
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 29s
extension / lint (push) Successful in 30s
Build images / build-web (push) Successful in 2m23s
Build images / build-ml (push) Successful in 3m20s
CI / integration (push) Successful in 3m52s
Build images / build-agent (push) Successful in 9m26s
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.
2026-08-27 09:26:58 -04:00
bvandeusen cd5444e3ae ci(extension): derive the version from commit TIME, not commit count (#3092)
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
2026-08-27 09:26:58 -04:00
15 changed files with 623 additions and 190 deletions
+196 -45
View File
@@ -2,10 +2,18 @@ name: Build images
on: on:
push: push:
# `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after # `:dev` builds were dropped 2026-05-26 to save a docker build per dev
# merge-to-main, not from the dev branch image. Saves one full docker # push, on the reasoning that "operator tests from `:latest` after
# build per dev push. # merge-to-main". Restored 2026-08-27: that is testing by shipping, and
branches: [main] # family rules 146/147 now name it directly — `main` IS production, and a
# channel that can only be refreshed by shipping is not a channel. The
# pressure to merge in order to try something does not come from
# carelessness; it comes from `:dev` being unable to carry the build.
#
# All three images build on dev, deliberately: a `:dev` web image paired
# with a stale `:dev` ml or agent is a worse trap than no dev channel at
# all, since the mismatch only shows up as a runtime failure.
branches: [main, dev]
# Tag-push triggers an immutable per-version image build (e.g. # Tag-push triggers an immutable per-version image build (e.g.
# `:v26.05.26.5`) — gives a real rollback story alongside the floating # `:v26.05.26.5`) — gives a real rollback story alongside the floating
# `:main` / `:latest`. Layer reuse keeps the registry-storage cost # `:main` / `:latest`. Layer reuse keeps the registry-storage cost
@@ -25,52 +33,135 @@ jobs:
# Forgejo release exists yet, otherwise downloads the cached signed XPI. # Forgejo release exists yet, otherwise downloads the cached signed XPI.
# Result is uploaded as an Actions artifact for build-web to consume. # Result is uploaded as an Actions artifact for build-web to consume.
# #
# Why this lives in build.yml (not a separate workflow): the merge-commit's # Why this lives in build.yml (not a separate workflow): the image a push
# docker image tagged `:latest` MUST carry the XPI. A separate sign workflow # publishes MUST carry the XPI. A separate sign workflow racing build.yml
# racing build.yml leaves `:latest` without the XPI for ~5min (until the # leaves that image without one for ~5min (until the commit-back triggers
# commit-back triggers another build). Inline ordering eliminates the race. # another build). Inline ordering eliminates the race.
# Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic # Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic
# Packages (cleaner API surface) and commit-back-to-side-branch (no extra # Packages (cleaner API surface) and commit-back-to-side-branch (no extra
# branch to manage). AMO blocks re-signing the same version (returns 409), # branch to manage). AMO blocks re-signing the same version (returns 409),
# so signing is intentionally one-shot per version bump. # so signing is intentionally one-shot per version.
#
# BOTH branches sign (milestone 271 step 6, 2026-08-27). 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 extension source. A dev
# push that changes the extension signs it; the merge to main then 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 is what makes two
# channels affordable, and it is why step 4 (derived version) had to land
# first. Ungating this while the version was still the hand-set 1.0.11 would
# have hit the existing ext-1.0.11 cache 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 (see build-web's race note, 2026-05-27).
sign-extension: sign-extension:
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# Full history: the shadow-mode step below derives a version from a # Full history is load-bearing, not a convenience: the version this
# commit count, which a depth-1 clone cannot produce. Harmless for # job signs is derived from the commit TIME of the newest packaged
# everything else in this job. # extension change. A depth-1 clone sees one commit and derives a
# wrong, too-low value rather than failing (ci-requirements.md).
fetch-depth: 0 fetch-depth: 0
- name: Resolve extension version # The version is DERIVED, not read from the repo (milestone 271 step 4,
# cut over 2026-08-27). `packaging.sh version` returns MAJOR.MINOR from
# manifest.json plus a patch component that is the commit TIME of the
# newest change to a PACKAGED extension file, in minutes since
# 2020-01-01 — family rule 149, never a commit count, which orders by
# branch rather than by recency.
#
# The committed "version" in manifest.json / package.json no longer
# decides anything: the stamp step below overwrites it in the working
# tree before web-ext ever reads it. It is deliberately NOT committed
# back — the commit carrying the bump would itself be a change to the
# extension and would move the version again. The repo holds the source;
# the build derives the label.
- name: Derive extension version
id: extver id: extver
run: | run: |
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') set -eu
VERSION=$(sh extension/scripts/packaging.sh version)
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved extension version: $VERSION" echo "Derived extension version: $VERSION"
# --- shadow mode (milestone #271, step 2) --------------------------- # Firefox refuses a downgrade and AMO never releases a burned version,
# Informational ONLY — nothing downstream reads this, and it must never # so a version that moves BACKWARDS is unrecoverable: it strands every
# fail the build. This is THE place the derived formula gets validated: # install that already took the higher one. Two ways it could happen —
# `sign-extension` only runs on main, so main pushes are the sole source # a checkout without full history (derives too low), or a rewritten
# of truth for whether the derived version moves exactly when the shipped # history that drops the newest packaged commit.
# extension changes. Compare these lines across several main builds #
# before step 4 lets the derived value control publishing. # The test is `derived < highest already signed`, strictly. Equality is
- name: Shadow — derived version (informational) # the ORDINARY case, not a fault: an unchanged extension derives the same
# version it did last build, which is exactly what lets the ext-<version>
# cache hit and holds AMO to one call per extension CHANGE. Only moving
# backwards is a failure, so this runs on every path — cache hit
# included — rather than only before a sign.
- name: Guard — the derived version must never go backwards
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
DERIVED: ${{ steps.extver.outputs.version }}
run: | run: |
set -u python3 - <<'PY'
DERIVED=$(sh extension/scripts/packaging.sh version 2>&1 || echo "UNAVAILABLE") import json, os, sys, urllib.request
MANUAL=${{ steps.extver.outputs.version }}
echo "shadow: manual=$MANUAL derived=$DERIVED sha=$GITHUB_SHA" API = ("https://git.fabledsword.com/api/v1/repos/"
if [ "$MANUAL" = "$DERIVED" ]; then "bvandeusen/FabledCurator/releases")
echo "shadow: manual and derived agree" headers = {"Authorization": "token " + os.environ["TOKEN"]}
else
echo "shadow: DIVERGENT — expected until step 4 cuts over; derived is authoritative-to-be" # Paginated rather than first-page-only: ext-* releases share this
fi # list with the v* release tags, so one page would start missing them
# as those accumulate. The bound FAILS rather than silently scanning
# part of the list and calling the highest it saw the highest there is.
tags = []
for page in range(1, 21):
req = urllib.request.Request(
f"{API}?limit=50&page={page}", headers=headers)
with urllib.request.urlopen(req, timeout=30) as resp:
batch = json.load(resp)
if not batch:
break
tags += [r.get("tag_name", "") for r in batch]
else:
sys.exit("guard: >1000 releases — pagination bound reached")
def parse(v):
try:
return tuple(int(part) for part in v.split("."))
except ValueError:
return None
derived_s = os.environ["DERIVED"]
derived = parse(derived_s)
if derived is None:
sys.exit(f"guard: derived version {derived_s!r} is not numeric")
signed = sorted(
(v, t) for t in tags if t.startswith("ext-")
for v in [parse(t[4:])] if v
)
if not signed:
print("guard: no ext-* release yet — nothing to go backwards from")
raise SystemExit(0)
hi, hi_tag = signed[-1]
print(f"guard: derived={derived_s} highest already signed={hi_tag}")
if derived < hi:
sys.exit(
f"REFUSING TO SIGN: derived {derived_s} is OLDER than the "
f"already-signed {hi_tag}. Firefox would reject it as a "
f"downgrade, and AMO will not release the burned version. "
f"First thing to check: did this job check out with "
f"fetch-depth: 0?"
)
print("guard: ok")
PY
- name: Check Forgejo release-asset cache - name: Check Forgejo release-asset cache
id: cache id: cache
@@ -108,6 +199,29 @@ jobs:
# removal — sign-extension's job is just to ensure the cache # removal — sign-extension's job is just to ensure the cache
# exists on Forgejo; the build-web side reads it independently). # exists on Forgejo; the build-web side reads it independently).
# web-ext signs whatever manifest.json says, so the derived value has to
# reach the tree before signing. package.json is written too: the two are
# required to agree (ci.yml's guard), and a local `npm run build` reads
# it. Working tree only — never committed, per the note on the derive
# step.
- name: Stamp the derived version into manifest.json + package.json
env:
DERIVED: ${{ steps.extver.outputs.version }}
run: |
python3 - <<'PY'
import json, os
version = os.environ["DERIVED"]
for path in ("extension/manifest.json", "extension/package.json"):
with open(path) as fh:
doc = json.load(fh)
doc["version"] = version
with open(path, "w") as fh:
json.dump(doc, fh, indent=2)
fh.write("\n")
print(f"{path}: version -> {version}")
PY
- name: Sign via AMO (cache miss) - name: Sign via AMO (cache miss)
if: steps.cache.outputs.cached != 'true' if: steps.cache.outputs.cached != 'true'
run: | run: |
@@ -134,6 +248,11 @@ jobs:
# created it so an upload failure below can roll back (don't # created it so an upload failure below can roll back (don't
# leave an empty release tombstone that the next run's # leave an empty release tombstone that the next run's
# cache-check mistakes for a partial-failure state). # cache-check mistakes for a partial-failure state).
#
# target_commitish is the signing commit, not a branch name: since
# step 6 either branch can create this release, and hard-coding
# `main` would tag a dev-signed XPI against a main commit that may
# not even contain the extension source it was built from.
STATUS=$(curl -s -o release.json -w "%{http_code}" \ STATUS=$(curl -s -o release.json -w "%{http_code}" \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
@@ -141,7 +260,7 @@ jobs:
CREATED_BY_US=false CREATED_BY_US=false
else else
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"main\"}" \ -d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"$GITHUB_SHA\"}" \
-o release.json \ -o release.json \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases" "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases"
CREATED_BY_US=true CREATED_BY_US=true
@@ -184,19 +303,31 @@ jobs:
build-web: build-web:
needs: [sign-extension] needs: [sign-extension]
# sign-extension is main-only; on dev it's skipped, build-web still runs. # sign-extension runs on main and dev, and is skipped on a tag push (which
# polls for the release instead). Either is fine to build on; a FAILED sign
# is not — this condition lets success and skipped through, so a failure
# skips build-web rather than shipping an image without the XPI.
if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped') if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# Full history: this job RE-DERIVES the extension version rather than
# being handed it, and a depth-1 clone derives a wrong, too-low value
# rather than failing — which would 404 the download of a release
# that exists perfectly well under its real name.
fetch-depth: 0
- name: Download signed XPI from Forgejo release asset (main + tags) - name: Download signed XPI from Forgejo release asset
# Fires on main-push AND on tag-push. Tag-push builds re-package the # Fires on every trigger shape. dev and main each bundle the XPI their
# same source code as the preceding main-push build but with an # own sign-extension just published — that is the whole point of the
# immutable version tag — they need the XPI too, otherwise the # channel work (milestone 271 step 6): the dev image carries the
# versioned image ships without the signed extension. # extension being developed, rather than requiring a merge to try it.
# Tag-push builds re-package the same source as the preceding main-push
# build but with an immutable version tag — they need the XPI too,
# otherwise the versioned image ships without the signed extension.
# #
# Tag-push vs main-push race (operator-flagged 2026-05-27 after # Tag-push vs main-push race (operator-flagged 2026-05-27 after
# v26.05.27.0 hit it): a release cut fires BOTH workflows almost # v26.05.27.0 hit it): a release cut fires BOTH workflows almost
@@ -208,12 +339,18 @@ jobs:
# for up to 10min total) before giving up. Main-push's signing # for up to 10min total) before giving up. Main-push's signing
# eventually wins and tag-push picks the release up on a later # eventually wins and tag-push picks the release up on a later
# iteration. # iteration.
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')
env: env:
TOKEN: ${{ secrets.RELEASE_TOKEN }} TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
set -eux set -eux
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') # Re-derived, not read from the repo: sign-extension published
# ext-<derived>, and the committed version has been inert since
# milestone 271 step 4. Both jobs run `packaging.sh version` over the
# same commit, so they agree by construction — and if they ever
# didn't, this download 404s and the build fails loudly instead of
# shipping a stale XPI.
VERSION=$(sh extension/scripts/packaging.sh version)
# Poll for the ext-<version> release. main-push's sign-extension # Poll for the ext-<version> release. main-push's sign-extension
# step (AMO round-trip, 1-5min) needs to finish + upload before # step (AMO round-trip, 1-5min) needs to finish + upload before
# tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail. # tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail.
@@ -279,21 +416,30 @@ jobs:
# rollback unit"). Rollback to any commit # rollback unit"). Rollback to any commit
# becomes `docker pull …:c-<sha>` without a # becomes `docker pull …:c-<sha>` without a
# release ceremony. # release ceremony.
# anything else → safety net; shouldn't fire given the `on:` # refs/heads/dev → push to dev: publish :dev, the rolling test
# config above. Tag :dev to surface the # channel (family rule 146). Rolling means it may
# unexpected run in the registry. # carry newer contents than the :c-<sha> of the
# same commit; it never writes :c-<sha> itself,
# because that is the rollback unit (rule 145).
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not # POSIX-safe substring (the runner shell is dash/BusyBox sh, not
# bash — `${var:0:7}` errors with "Bad substitution"; cut works # bash — `${var:0:7}` errors with "Bad substitution"; cut works
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha> # everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
# main-push build failed at this step. # main-push build failed at this step.
SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7) SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7)
# `channel` is baked into the image as FC_CHANNEL and reported by
# /api/extension/manifest (milestone 271 step 7). 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.
if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then
TAG_NAME="${GITHUB_REF#refs/tags/}" TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
echo "channel=main" >> "$GITHUB_OUTPUT"
elif [ "${GITHUB_REF##*/}" = "main" ]; then elif [ "${GITHUB_REF##*/}" = "main" ]; then
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:main,git.fabledsword.com/bvandeusen/fabledcurator:latest,git.fabledsword.com/bvandeusen/fabledcurator:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT"
echo "channel=main" >> "$GITHUB_OUTPUT"
else else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
echo "channel=dev" >> "$GITHUB_OUTPUT"
fi fi
- name: Login to Forgejo registry - name: Login to Forgejo registry
@@ -310,6 +456,11 @@ jobs:
file: Dockerfile file: Dockerfile
push: true push: true
tags: ${{ steps.tag.outputs.tags }} tags: ${{ steps.tag.outputs.tags }}
# Only the web image carries a channel: it is the one that serves
# /api/extension/manifest. The ml and agent images have nothing to
# report it to.
build-args: |
FC_CHANNEL=${{ steps.tag.outputs.channel }}
build-ml: build-ml:
runs-on: python-ci runs-on: python-ci
+46 -106
View File
@@ -2,7 +2,7 @@ name: CI
# CI lanes per FabledRulebook/forgejo.md "CI philosophy": # CI lanes per FabledRulebook/forgejo.md "CI philosophy":
# - lint: ruff only, no dep install — fast-fail for the common lint bounce. # - 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 MAJOR.MINOR agrees.
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers. # - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
# - frontend-build: vitest unit + vite build. # - frontend-build: vitest unit + vite build.
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`. # - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
@@ -42,18 +42,29 @@ jobs:
# catching syntax errors before the image build. # catching syntax errors before the image build.
run: python -m compileall -q agent/fc_agent 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 # Two things are still worth asserting, and this is the only lane that can:
# version string in extension/package.json: if an `ext-<version>` Forgejo # the extension.yml suite runs on node:24-slim, which is exactly why
# release already carries an XPI, signing is SKIPPED and that old signed XPI # version.spec.js sticks to packaging.sh's git-free subcommands.
# is what build-web bakes into `:latest`. Nothing in that path inspects # 1. the derivation actually resolves on this commit
# whether extension/ actually changed — so a forgotten version bump ships a # 2. MAJOR.MINOR agrees between the two files — the one part still hand-set,
# stale extension on a fully green build, silently. (AMO can't help: it 409s # and packaging.sh reads it from manifest.json ALONE, so a divergence
# on re-signing a version, which is exactly why the cache exists.) # ships a version package.json disagrees with
# #
# This job makes that case loud, on the dev push, instead of invisible at # Deliberately NOT checked here: that the derived value beats what has already
# merge-to-main. It is pure git + text work — no deps, no services. # 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: extension-version:
runs-on: python-ci runs-on: python-ci
container: container:
@@ -61,108 +72,37 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# Full history: the check diffs against the push's `before` SHA (or # The derivation needs real history: a depth-1 clone sees one commit
# the PR base), which a depth-1 clone wouldn't contain. # and produces a wrong, too-low value RATHER THAN FAILING. Checking
# that here is half the point of the lane.
fetch-depth: 0 fetch-depth: 0
- name: Extension version guard - name: Extension version derives cleanly
env:
BEFORE: ${{ github.event.before }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
run: | run: |
set -eu set -eu
# busybox sh on the act_runner — no bashisms (family rule). # busybox sh on the act_runner — no bashisms (family rule).
ver() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'; } VERSION=$(sh extension/scripts/packaging.sh version)
PKG=$(ver extension/package.json) echo "derived: $VERSION"
MAN=$(ver extension/manifest.json) # The shape AMO accepts, and the shape build.yml will stamp.
test -n "$PKG" || { echo "ERROR: no version found in extension/package.json"; exit 1; } if ! echo "$VERSION" | grep -qE '^[0-9]+(\.[0-9]+)*$'; then
test -n "$MAN" || { echo "ERROR: no version found in extension/manifest.json"; exit 1; } echo "ERROR: derived version '$VERSION' is not plain dotted-numeric."
echo "AMO would reject it, and build.yml stamps it verbatim."
# --- shadow mode (milestone #271, step 2) ------------------------- exit 1
# Informational ONLY: nothing below reads DERIVED, and this must never fi
# fail the job. `web-ext sign` is one-shot per version (AMO 409s on a mm() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+\.[0-9]+).*/\1/'; }
# repeat), so a wrong formula would burn a real version number that MAN=$(mm extension/manifest.json)
# can't be reclaimed. Logging it against real pushes first is the only PKG=$(mm extension/package.json)
# way to validate it at zero cost. test -n "$MAN" || { echo "ERROR: no parseable version in extension/manifest.json"; exit 1; }
# Placed before every early-exit path so it reports on all runs. test -n "$PKG" || { echo "ERROR: no parseable version in extension/package.json"; exit 1; }
DERIVED=$(sh extension/scripts/packaging.sh version 2>&1 || echo "UNAVAILABLE")
echo "shadow: manual=$PKG derived=$DERIVED"
# -----------------------------------------------------------------
# (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 if [ "$MAN" != "$PKG" ]; then
echo "ERROR: extension version mismatch." echo "ERROR: MAJOR.MINOR disagrees between the two files."
echo " extension/manifest.json = $MAN <- what AMO signs / Firefox installs" echo " extension/manifest.json = $MAN <- packaging.sh reads MAJOR.MINOR from here"
echo " extension/package.json = $PKG <- what CI caches, names, and reports" echo " extension/package.json = $PKG"
echo "Set both to the same value." echo "Only MAJOR.MINOR is hand-set. The patch component is derived from"
echo "commit time and overwritten at build time, so the committed patch"
echo "numbers are inert — but MAJOR.MINOR still ships. Set both the same."
exit 1 exit 1
fi fi
echo "OK: MAJOR.MINOR $MAN, derived version $VERSION"
# (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."
exit 1
fi
echo "OK: extension version $PKG_OLD -> $PKG"
backend-lint-and-test: backend-lint-and-test:
runs-on: python-ci runs-on: python-ci
+8 -3
View File
@@ -10,15 +10,20 @@ on:
paths: paths:
- 'extension/**' - 'extension/**'
- '.forgejo/workflows/extension.yml' - '.forgejo/workflows/extension.yml'
# test/version.spec.js asserts ci.yml's extension-version guard never # test/version.spec.js asserts things ABOUT the other two workflows —
# ignores a file web-ext actually packages, so a ci.yml-only edit can # that neither inlines the packaged-file set, and that build.yml derives
# break this suite and must trigger it. # 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/ci.yml'
- '.forgejo/workflows/build.yml'
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
- 'extension/**' - 'extension/**'
- '.forgejo/workflows/ci.yml' - '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
+17
View File
@@ -47,6 +47,23 @@ RUN chmod +x entrypoint.sh
COPY --from=frontend-builder /build/dist ./frontend/dist 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
# this is the one value that differs between the dev and main builds of
# identical source — put it any earlier and the two channels could never share
# a cached pip install.
ARG FC_CHANNEL=""
ENV FC_CHANNEL=${FC_CHANNEL}
EXPOSE 8080 EXPOSE 8080
ENTRYPOINT ["./entrypoint.sh"] ENTRYPOINT ["./entrypoint.sh"]
+2 -2
View File
@@ -19,7 +19,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. | | **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. | | **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`. | | **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. | | **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
## Quick start ## Quick start
@@ -52,7 +52,7 @@ FabledCurator is designed to run inside a self-hosted homelab environment over p
## CI / Forgejo setup ## CI / Forgejo setup
Three workflows: `ci.yml` (lint, extension-version guard, backend unit tests, Three workflows: `ci.yml` (lint, extension-version check, backend unit tests,
frontend build, integration), `extension.yml` (extension lint, vitest, XPI frontend build, integration), `extension.yml` (extension lint, vitest, XPI
content verification), and `build.yml` (sign + publish). content verification), and `build.yml` (sign + publish).
+25 -1
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import asyncio import asyncio
import hashlib import hashlib
import hmac import hmac
import os
import re import re
from pathlib import Path from pathlib import Path
@@ -31,6 +32,12 @@ XPI_DIR = Path("/app/frontend/dist/extension")
_XPI_VERSION_RE = re.compile(r"fabledcurator-(?P<version>[\w.-]+)\.xpi$") _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 from the FC_CHANNEL build arg (milestone 271 step 7). Empty for a local
# build, or for any image predating the field. Tests override by monkeypatching
# this constant, same as XPI_DIR above.
FC_CHANNEL = os.environ.get("FC_CHANNEL", "").strip()
async def _ext_key_required(session) -> bool: async def _ext_key_required(session) -> bool:
"""Unlike /api/credentials (which accepts the browser path with no """Unlike /api/credentials (which accepts the browser path with no
@@ -133,13 +140,30 @@ def _read_manifest_sync() -> dict | None:
return None return None
versioned.sort(key=lambda p: p.stat().st_mtime) versioned.sort(key=lambda p: p.stat().st_mtime)
latest = versioned[-1] latest = versioned[-1]
return { info = {
"installed": True, "installed": True,
"version": _extract_version(latest.name), "version": _extract_version(latest.name),
"xpi_url": f"/extension/{latest.name}", "xpi_url": f"/extension/{latest.name}",
"latest_url": "/extension/fabledcurator-latest.xpi", "latest_url": "/extension/fabledcurator-latest.xpi",
"sha256": _sha256(latest), "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"]) @extension_bp.route("/manifest", methods=["GET"])
+28 -7
View File
@@ -54,13 +54,34 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
shims to production code — the libs ship as `background.scripts`, not ES shims to production code — the libs ship as `background.scripts`, not ES
modules, so the specs exercise exactly the bytes packaged into the XPI. modules, so the specs exercise exactly the bytes packaged into the XPI.
- **`extension/scripts/packaging.sh` is the single definition of what ships - **`extension/scripts/packaging.sh` is the single definition of what ships
inside the XPI.** Three consumers read from it rather than keeping their own inside the XPI.** Two consumers read from it rather than keeping their own
copy: web-ext's `--ignore-files` (`extension/package.json`), the `:(exclude)` copy: web-ext's `--ignore-files` (`extension/package.json`), and the `git log`
pathspec in `ci.yml`'s `extension-version` guard, and the commit count that pathspec inside the script's own version derivation. It was three until
derives the extension version. Three hand-kept copies of that one fact is 2026-08-27 — `ci.yml`'s `extension-version` guard held the third and went when
what allowed issue #2397. the manual bump it guarded did (milestone 271 step 5). Hand-kept copies of
- `build.yml`'s `sign-extension` checks out with `fetch-depth: 0` — the derived that one fact is what allowed issue #2397, so `extension/test/version.spec.js`
extension version is a commit count, which a shallow clone cannot produce. asserts no workflow has reintroduced a literal `:(exclude)extension/…`.
- **The shipped extension version is derived, not committed.** It 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). `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. Treat the version in the repo as a base: only
its MAJOR.MINOR is read, and its patch component is inert.
- Every job that calls `packaging.sh version` checks out with `fetch-depth: 0`
`build.yml`'s `sign-extension` and `build-web`, and `ci.yml`'s
`extension-version`. 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.
- **`FC_CHANNEL` is a build arg, not a runtime setting.** `build.yml` passes
`dev` / `main` to the web image only (the ml and agent images have nothing to
report it to), and `/api/extension/manifest` reports it beside the version so
an install can be traced to a channel. It is declared LAST in the Dockerfile
on purpose: an ARG invalidates every layer below it, and this is the one value
that differs between the dev and main builds of identical source, so placing
it earlier would stop the two channels ever sharing a cached `pip install`.
Empty by default — a local build then reports no channel at all rather than
claiming one.
- Callers MUST `set -f` before substituting the script's output. Without it the - Callers MUST `set -f` before substituting the script's output. Without it the
shell expands `test/**` against the working tree and silently narrows 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 pattern to whatever files exist at that moment — a failure that looks like
+59 -6
View File
@@ -7,7 +7,8 @@ page in one click.
## Install (operator) ## 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 Settings → Maintenance → Browser extension → click "Install Firefox
extension". Firefox shows its native install prompt. After installing, extension". Firefox shows its native install prompt. After installing,
open the extension's options page (about:addons → FabledCurator → open the extension's options page (about:addons → FabledCurator →
@@ -20,6 +21,7 @@ same card.
cd extension/ cd extension/
npm install --no-save # web-ext only npm install --no-save # web-ext only
npm run lint # web-ext lint 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 start # launches Firefox with extension loaded
npm run build # unsigned XPI in web-ext-artifacts/ npm run build # unsigned XPI in web-ext-artifacts/
``` ```
@@ -36,10 +38,61 @@ npm run build # unsigned XPI in web-ext-artifacts/
- [ ] Subscriptions list: popup → "Sources" tab → list renders - [ ] Subscriptions list: popup → "Sources" tab → list renders
- [ ] Check now: click play icon on source row → no error toast - [ ] Check now: click play icon on source row → no error toast
## Versioning — don't hand-edit the patch number
The shipped version is **derived**, not committed. `scripts/packaging.sh
version` returns `MAJOR.MINOR` from `manifest.json` plus a patch component
that is the commit *time* of the newest change to a packaged extension file,
in minutes since 2020-01-01. `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 patch number does nothing.** It is overwritten before web-ext
ever reads it. There is no bump to make, and none to forget.
- **MAJOR.MINOR is still yours.** It carries the deliberate meaning, it is read
from `manifest.json` alone, and CI fails the `extension-version` lane if the
two files disagree on it.
- `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 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 ## Release
Bump `manifest.json` + `package.json` SemVer (both files) and commit Nothing to do by hand. Push to `dev`: `build.yml` signs the extension if this
under `extension/**`. The `.forgejo/workflows/extension.yml` workflow change moved the version, caches the signed XPI as a Forgejo `ext-<version>`
runs `web-ext sign` on main, commits the signed XPI to release, and bundles it into `fabledcurator:dev`. Merging to `main` derives the
`frontend/public/extension/`, and the next FC server build bundles it same version, hits that cache, and bundles the byte-identical XPI into
into the Docker image. `: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 // 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 // reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
// a toolbar badge (daily). /api/extension/manifest is public and returns // 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) { function versionIsNewer(candidate, current) {
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't). // 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 currentVersion = browser.runtime.getManifest().version;
const latestVersion = info && info.version ? info.version : null; 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. // latest_url is served from the web root, not the JSON API.
const base = api.webRoot(); const base = api.webRoot();
return { return {
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion), updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
currentVersion, currentVersion,
latestVersion, latestVersion,
channel,
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null, xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
}; };
} }
@@ -77,7 +96,11 @@ async function refreshUpdateBadge() {
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' }); await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
if (r.updateAvailable) { if (r.updateAvailable) {
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' }); 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 { } else {
await browser.action.setTitle({ title: 'FabledCurator' }); await browser.action.setTitle({ title: 'FabledCurator' });
} }
+5 -1
View File
@@ -81,8 +81,12 @@ async function checkForUpdate() {
} }
function showUpdateBanner(r) { 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 = 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. // Opening the signed XPI triggers Firefox's native install prompt.
document.getElementById('update-btn').addEventListener('click', () => { document.getElementById('update-btn').addEventListener('click', () => {
browser.tabs.create({ url: r.xpiUrl }); browser.tabs.create({ url: r.xpiUrl });
+40 -9
View File
@@ -7,7 +7,7 @@
# #
# 1. web-ext's --ignore-files (extension/package.json's four scripts) # 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 (ci.yml's extension-version guard)
# 3. the rev-list pathspec (the derived version, below) # 3. the git-log pathspec (the derived version, below)
# #
# They now all read from here. POSIX sh only — CI's run shell is busybox. # They now all read from here. POSIX sh only — CI's run shell is busybox.
# #
@@ -68,20 +68,51 @@ cmd_major_minor() {
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+)\.([0-9]+).*/\1.\2/' | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+)\.([0-9]+).*/\1.\2/'
} }
# Count of commits that touched a PACKAGED extension file. Monotonic on a # 2020-01-01T00:00:00Z — the anchor for the derived patch component. Fixed
# branch (the count only grows), which is a correctness requirement, not a # forever; moving it would renumber every version downwards.
# nicety: Firefox refuses to install a version lower than the one present. VERSION_EPOCH=1577836800
# Minutes since VERSION_EPOCH of the LATEST commit that touched a PACKAGED
# extension file.
# #
# Merge commits need no special handling — git's history simplification already # Time-derived, per family rule 149: an artifact's ordering key must never be a
# prunes merges that don't change the pathspec, so --no-merges is a no-op here # commit count. A count is per-branch — `dev` and `main` count different
# (verified on main: both forms return the same count). # 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.
#
# 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.
# * 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
# minutes would re-sign on every push and never let two channels share a
# signature.
# * SHARED ACROSS CHANNELS — 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.
# * REPRODUCIBLE — any checkout of a commit yields that commit's version.
#
# 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() { cmd_patch() {
root=$(git rev-parse --show-toplevel) root=$(git rev-parse --show-toplevel)
# Unquoted on purpose: the pathspec must word-split into separate args. # Unquoted on purpose: the pathspec must word-split into separate args.
# Globbing is already off script-wide (set -euf above). # Globbing is already off script-wide (set -euf above).
# shellcheck disable=SC2046 # shellcheck disable=SC2046
count=$(cd "$root" && git rev-list --count HEAD -- extension/ $(cmd_pathspec)) ts=$(cd "$root" && git log --format=%ct HEAD -- extension/ $(cmd_pathspec) \
echo "$count" | sort -n | tail -1)
if [ -z "$ts" ]; then
echo "packaging.sh: no commit touches a packaged extension file" >&2
exit 1
fi
echo $(( (ts - VERSION_EPOCH) / 60 ))
} }
cmd_version() { cmd_version() {
+43 -8
View File
@@ -77,21 +77,56 @@ describe('consumers delegate rather than keeping their own copy', () => {
} }
}) })
it('ci.yml derives its pathspec from the script and hardcodes none', () => { const WORKFLOWS = ['ci.yml', 'build.yml', 'extension.yml']
const ci = readText('..', '.forgejo', 'workflows', 'ci.yml')
expect(ci).toContain('extension/scripts/packaging.sh pathspec') it('no workflow hardcodes the packaged-file set', () => {
// A literal :(exclude)extension/... in the workflow means someone bypassed // ci.yml used to substitute `packaging.sh pathspec` directly, for the
// the shared definition. // manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
expect(ci).not.toMatch(/:\(exclude\)extension\//) // 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. Asserted across all three rather than
// against one named consumer, so it keeps holding as consumers come and go.
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', () => { describe('extension version', () => {
it('keeps manifest.json and package.json in lockstep', () => { const majorMinor = (v) => v.split('.').slice(0, 2).join('.')
expect(read('manifest.json').version).toBe(read('package.json').version)
it('keeps the hand-set MAJOR.MINOR in lockstep across both files', () => {
// Narrowed from full-string equality at milestone 271 step 5. Since step 4
// the patch component is derived from commit time and stamped into both
// files at build time, so the committed patch numbers are inert — nothing
// reads them and they are not what ships. Asserting on them would fail for
// a difference that changes nothing.
//
// MAJOR.MINOR is the opposite: still hand-set, still shipped, and
// packaging.sh reads it from manifest.json ALONE. Let the two diverge and
// the extension ships a version package.json disagrees with, with no other
// signal.
expect(majorMinor(read('manifest.json').version))
.toBe(majorMinor(read('package.json').version))
}) })
it('uses a plain dotted numeric version AMO will accept', () => { it('uses a plain dotted numeric version AMO will accept', () => {
// The committed value seeds MAJOR.MINOR, so it still has to parse even
// though its patch component never ships. ci.yml asserts the same shape on
// the DERIVED value, which is the one AMO actually sees.
expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/) expect(read('package.json').version).toMatch(/^\d+(\.\d+)*$/)
}) })
@@ -4,6 +4,16 @@
<span v-if="manifest?.installed" class="text-caption fc-muted"> <span v-if="manifest?.installed" class="text-caption fc-muted">
· Firefox · v{{ manifest.version }} · Firefox · v{{ manifest.version }}
</span> </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> </CardHeading>
<v-card-text> <v-card-text>
@@ -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)
})
})
+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() 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> ----------------------------------------- # --- /extension/<filename> -----------------------------------------