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
14 changed files with 399 additions and 129 deletions
+12
View File
@@ -426,13 +426,20 @@ jobs:
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
# main-push build failed at this step.
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
TAG_NAME="${GITHUB_REF#refs/tags/}"
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:${TAG_NAME}" >> "$GITHUB_OUTPUT"
echo "channel=main" >> "$GITHUB_OUTPUT"
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 "channel=main" >> "$GITHUB_OUTPUT"
else
echo "tags=git.fabledsword.com/bvandeusen/fabledcurator:dev" >> "$GITHUB_OUTPUT"
echo "channel=dev" >> "$GITHUB_OUTPUT"
fi
- name: Login to Forgejo registry
@@ -449,6 +456,11 @@ jobs:
file: Dockerfile
push: true
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:
runs-on: python-ci
+46 -95
View File
@@ -2,7 +2,7 @@ name: CI
# CI lanes per FabledRulebook/forgejo.md "CI philosophy":
# - lint: ruff only, no dep install — fast-fail for the common lint bounce.
# - extension-version: guards the extension publish path (see the job).
# - extension-version: the derived version resolves and MAJOR.MINOR agrees.
# - backend-lint-and-test: `pytest -m "not integration"`, no service containers.
# - frontend-build: vitest unit + vite build.
# - integration: pgvector + redis service containers; alembic + `pytest -m integration`.
@@ -42,18 +42,29 @@ jobs:
# catching syntax errors before the image build.
run: python -m compileall -q agent/fc_agent
# Guards the extension publish path, which has no self-correcting behavior.
# The extension version is DERIVED, not hand-maintained (milestone 271 step
# 4): build.yml computes it from the commit TIME of the newest packaged
# extension change and stamps it into manifest.json / package.json at build
# time. The guard that used to live here — "packaged files changed but nobody
# bumped the version" — was therefore checking a fact that had stopped
# existing. Worse than useless: it would have failed this lane on every real
# extension change, demanding a bump that decides nothing. Retired 2026-08-27
# rather than left running beside the new mechanism (rule 22).
#
# build.yml's sign-extension job keys its AMO-signing cache purely on the
# version string in extension/package.json: if an `ext-<version>` Forgejo
# release already carries an XPI, signing is SKIPPED and that old signed XPI
# is what build-web bakes into `:latest`. Nothing in that path inspects
# whether extension/ actually changed — so a forgotten version bump ships a
# stale extension on a fully green build, silently. (AMO can't help: it 409s
# on re-signing a version, which is exactly why the cache exists.)
# Two things are still worth asserting, and this is the only lane that can:
# the extension.yml suite runs on node:24-slim, which is exactly why
# version.spec.js sticks to packaging.sh's git-free subcommands.
# 1. the derivation actually resolves on this commit
# 2. MAJOR.MINOR agrees between the two files — 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
#
# This job makes that case loud, on the dev push, instead of invisible at
# merge-to-main. It is pure git + text work — no deps, no services.
# Deliberately NOT checked here: that the derived value beats what has already
# been signed. That guard belongs in build.yml, where it compares against the
# real ext-* releases. Comparing against origin/main here would be wrong —
# dev legitimately derives a LOWER value whenever main is ahead on the
# extension, and a lane that fails for being behind is a lane people learn to
# ignore.
extension-version:
runs-on: python-ci
container:
@@ -61,97 +72,37 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
# Full history: the check diffs against the push's `before` SHA (or
# the PR base), which a depth-1 clone wouldn't contain.
# The derivation needs real history: a depth-1 clone sees one commit
# and produces a wrong, too-low value RATHER THAN FAILING. Checking
# that here is half the point of the lane.
fetch-depth: 0
- name: Extension version guard
env:
BEFORE: ${{ github.event.before }}
PR_BASE: ${{ github.event.pull_request.base.sha }}
- name: Extension version derives cleanly
run: |
set -eu
# busybox sh on the act_runner — no bashisms (family rule).
ver() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'; }
PKG=$(ver extension/package.json)
MAN=$(ver extension/manifest.json)
test -n "$PKG" || { echo "ERROR: no version found in extension/package.json"; exit 1; }
test -n "$MAN" || { echo "ERROR: no version found in extension/manifest.json"; exit 1; }
# (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.
VERSION=$(sh extension/scripts/packaging.sh version)
echo "derived: $VERSION"
# The shape AMO accepts, and the shape build.yml will stamp.
if ! echo "$VERSION" | grep -qE '^[0-9]+(\.[0-9]+)*$'; then
echo "ERROR: derived version '$VERSION' is not plain dotted-numeric."
echo "AMO would reject it, and build.yml stamps it verbatim."
exit 1
fi
mm() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+\.[0-9]+).*/\1/'; }
MAN=$(mm extension/manifest.json)
PKG=$(mm extension/package.json)
test -n "$MAN" || { echo "ERROR: no parseable version in extension/manifest.json"; exit 1; }
test -n "$PKG" || { echo "ERROR: no parseable version in extension/package.json"; exit 1; }
if [ "$MAN" != "$PKG" ]; then
echo "ERROR: extension version mismatch."
echo " extension/manifest.json = $MAN <- what AMO signs / Firefox installs"
echo " extension/package.json = $PKG <- what CI caches, names, and reports"
echo "Set both to the same value."
echo "ERROR: MAJOR.MINOR disagrees between the two files."
echo " extension/manifest.json = $MAN <- packaging.sh reads MAJOR.MINOR from here"
echo " extension/package.json = $PKG"
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
fi
# (2) If the SHIPPED extension changed, the version must have moved.
#
# Compare against MAIN, not against the previous push. The publish
# decision is made at merge-to-main against whatever ext-<version>
# already exists, so "differs from main" is the question that matters.
# Diffing against the previous dev push instead would demand a fresh
# bump on every iteration — push, tweak the extension again, and CI
# would insist on a second bump that buys nothing, inflating the
# version for no reason. On a main push there is no "main to compare
# to" yet, so fall back to that push's own before-SHA.
if [ "${GITHUB_REF##*/}" = "main" ]; then
BASE="${BEFORE:-}"
else
BASE=$(git rev-parse --verify -q origin/main 2>/dev/null || git rev-parse --verify -q main 2>/dev/null || echo "")
# PR base is the fallback when main isn't in the clone at all.
[ -n "$BASE" ] || BASE="${PR_BASE:-}"
fi
case "$BASE" in
''|0000000000000000000000000000000000000000)
echo "No usable base ref (no main in clone / first push) — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
;;
esac
if ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then
echo "Base commit $BASE not in this clone — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
fi
# The exclusion list is NOT written out here — it comes from
# extension/scripts/packaging.sh, the one definition of what ships,
# shared with web-ext's --ignore-files and the derived-version patch
# count. Three hand-kept copies of that fact is how #2397 happened.
#
# `set -f` is required around the substitution: without it the shell
# globs `test/**` against the working tree and silently narrows it.
set -f
CHANGED=$(git diff --name-only "$BASE" HEAD -- extension/ $(sh extension/scripts/packaging.sh pathspec))
set +f
if [ -z "$CHANGED" ]; then
echo "No packaged extension files changed since $BASE — nothing to guard."
echo "OK: extension version $PKG"
exit 0
fi
echo "Packaged extension files changed since $BASE:"
echo "$CHANGED" | sed 's/^/ /'
PKG_OLD=$(git show "$BASE:extension/package.json" 2>/dev/null | grep -E '"version"' | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/')
if [ -z "$PKG_OLD" ]; then
echo "Could not read the base version — skipping the bump check."
echo "OK: extension version $PKG"
exit 0
fi
if [ "$PKG_OLD" = "$PKG" ]; then
echo "ERROR: packaged extension files changed but the version is still $PKG."
echo "build.yml would find the existing ext-$PKG release, skip AMO signing,"
echo "and bake the OLD signed XPI into :latest — a green build shipping stale code."
echo "Bump the version in BOTH extension/package.json and extension/manifest.json."
exit 1
fi
echo "OK: extension version $PKG_OLD -> $PKG"
echo "OK: MAJOR.MINOR $MAN, derived version $VERSION"
backend-lint-and-test:
runs-on: python-ci
+8 -3
View File
@@ -10,15 +10,20 @@ on:
paths:
- 'extension/**'
- '.forgejo/workflows/extension.yml'
# test/version.spec.js asserts ci.yml's extension-version guard never
# ignores a file web-ext actually packages, so a ci.yml-only edit can
# break this suite and must trigger it.
# test/version.spec.js asserts things ABOUT the other two workflows —
# that neither inlines the packaged-file set, and that build.yml derives
# the shipped version rather than reading it out of the repo. A
# workflow-only edit can therefore break this suite, so it has to trigger
# it. build.yml joined the list at milestone 271 step 5, when the spec
# started asserting against it.
- '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
pull_request:
branches: [main]
paths:
- 'extension/**'
- '.forgejo/workflows/ci.yml'
- '.forgejo/workflows/build.yml'
workflow_dispatch:
jobs:
+17
View File
@@ -47,6 +47,23 @@ RUN chmod +x entrypoint.sh
COPY --from=frontend-builder /build/dist ./frontend/dist
# Which channel this image belongs to — `dev` or `main` (milestone 271 step 7).
# build.yml passes it; /api/extension/manifest reports it beside the version so
# an operator can tell which channel an install came from without the channel
# ever touching the version string.
#
# Empty by default, deliberately: a locally-built image then reports NO channel
# rather than claiming to be one, and the manifest omits the field entirely —
# indistinguishable from an image built before the field existed, which is
# exactly the shape every reader already has to handle.
#
# Declared LAST on purpose. An ARG/ENV invalidates every layer below it, and
# 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
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. |
| **ML worker** | `Dockerfile.ml` | `fabledcurator-ml` | Same app, plus `requirements-ml.txt` — tagging and embedding models that run in-container. |
| **GPU agent** | `agent/Dockerfile` | `fabledcurator-agent` | Optional desktop-GPU worker (`agent/`). Leases jobs over **HTTP only** — never touches the database or Redis. Run it for a burst, stop it to reclaim the card. See `agent/README.md`. |
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on `main` only, then bundled into the web image and served from Settings → Maintenance. See `extension/README.md`. |
| **Firefox extension** | `extension/` | signed XPI | MV3 extension: pushes platform session cookies into FC and adds a creator as a Source in one click. AMO-signed on both `dev` and `main` (one signature per extension change, shared by the two channels), bundled into that channel's web image and served from Settings → Maintenance. See `extension/README.md`. |
| **Data** | — | `pgvector/pgvector:pg16`, `redis:7-alpine` | Postgres with pgvector for embeddings; Redis as the Celery broker. |
## Quick start
@@ -52,7 +52,7 @@ FabledCurator is designed to run inside a self-hosted homelab environment over p
## CI / Forgejo setup
Three workflows: `ci.yml` (lint, extension-version guard, backend unit tests,
Three workflows: `ci.yml` (lint, extension-version check, backend unit tests,
frontend build, integration), `extension.yml` (extension lint, vitest, XPI
content verification), and `build.yml` (sign + publish).
+25 -1
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import asyncio
import hashlib
import hmac
import os
import re
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$")
# 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:
"""Unlike /api/credentials (which accepts the browser path with no
@@ -133,13 +140,30 @@ def _read_manifest_sync() -> dict | None:
return None
versioned.sort(key=lambda p: p.stat().st_mtime)
latest = versioned[-1]
return {
info = {
"installed": True,
"version": _extract_version(latest.name),
"xpi_url": f"/extension/{latest.name}",
"latest_url": "/extension/fabledcurator-latest.xpi",
"sha256": _sha256(latest),
}
# The channel goes BESIDE the version, never inside it. A `-dev` suffix is
# what silently disabled the dev channel in the sibling project this design
# comes from: the comparator returned nothing for a non-integer segment, so
# every dev version compared equal and "no update available" became
# indistinguishable from "I cannot read this version".
#
# Omitted rather than defaulted when unset. Absence already has a meaning
# every reader must handle — an image built before this field existed says
# exactly the same thing by not having the key — so a blank channel reuses
# that path instead of inventing a second "unknown" spelling.
#
# Reported verbatim, not validated against {"dev", "main"}: if an image
# declares something else, showing what it actually claims is more useful
# to whoever is debugging it than dropping the value on the floor.
if FC_CHANNEL:
info["channel"] = FC_CHANNEL
return info
@extension_bp.route("/manifest", methods=["GET"])
+28 -11
View File
@@ -54,17 +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
modules, so the specs exercise exactly the bytes packaged into the XPI.
- **`extension/scripts/packaging.sh` is the single definition of what ships
inside the XPI.** Three consumers read from it rather than keeping their own
copy: web-ext's `--ignore-files` (`extension/package.json`), the `:(exclude)`
pathspec in `ci.yml`'s `extension-version` guard, and the `git log` pathspec
that derives the extension version. Three hand-kept copies of that one fact
is what allowed issue #2397.
- Jobs that derive the extension version check out with `fetch-depth: 0`. The
version is the commit TIME of the newest packaged-extension change (minutes
since 2020-01-01, per family rule 149 — never a commit count, which orders
by branch rather than by recency). A depth-1 clone sees one commit and
derives a wrong, too-low value rather than failing, so the full-history
checkout is load-bearing wherever `packaging.sh version` is called.
inside the XPI.** Two consumers read from it rather than keeping their own
copy: web-ext's `--ignore-files` (`extension/package.json`), and the `git log`
pathspec inside the script's own version derivation. It was three until
2026-08-27 — `ci.yml`'s `extension-version` guard held the third and went when
the manual bump it guarded did (milestone 271 step 5). Hand-kept copies of
that one fact is what allowed issue #2397, so `extension/test/version.spec.js`
asserts no workflow has reintroduced a literal `:(exclude)extension/…`.
- **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
shell expands `test/**` against the working tree and silently narrows the
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)
The signed XPI is bundled into the FC Docker image. Open FC →
The signed XPI is bundled into the FC Docker image`:dev` and
`:latest` each carry their own channel's build. Open FC →
Settings → Maintenance → Browser extension → click "Install Firefox
extension". Firefox shows its native install prompt. After installing,
open the extension's options page (about:addons → FabledCurator →
@@ -20,6 +21,7 @@ same card.
cd extension/
npm install --no-save # web-ext only
npm run lint # web-ext lint
npm run test:unit # vitest — lib/ logic + packaging/version checks
npm run start # launches Firefox with extension loaded
npm run build # unsigned XPI in web-ext-artifacts/
```
@@ -36,10 +38,61 @@ npm run build # unsigned XPI in web-ext-artifacts/
- [ ] Subscriptions list: popup → "Sources" tab → list renders
- [ ] Check now: click play icon on source row → no error toast
## Versioning — 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
Bump `manifest.json` + `package.json` SemVer (both files) and commit
under `extension/**`. The `.forgejo/workflows/extension.yml` workflow
runs `web-ext sign` on main, commits the signed XPI to
`frontend/public/extension/`, and the next FC server build bundles it
into the Docker image.
Nothing to do by hand. Push to `dev`: `build.yml` signs the extension if this
change moved the version, caches the signed XPI as a Forgejo `ext-<version>`
release, and bundles it into `fabledcurator:dev`. Merging to `main` derives the
same version, hits that cache, and bundles the byte-identical XPI into
`:latest` with no second AMO call.
AMO refuses to re-sign a version it has already issued, so signing is one-shot
per version — which is why the cache exists and why the version must never move
backwards.
+25 -2
View File
@@ -37,7 +37,16 @@ ensureInitialized().catch(e => console.error('init failed:', e));
// configured backend for the latest published version and nudge the operator to
// reinstall the freshly-signed XPI — surfaced as a popup banner (on demand) and
// a toolbar badge (daily). /api/extension/manifest is public and returns
// {version, latest_url, sha256}; the XPI is served from the web root (not /api).
// {version, latest_url, sha256} plus an OPTIONAL {channel} naming which channel
// that instance serves ("dev"/"main", #3113); the XPI is served from the web
// root (not /api).
//
// The channel IS the instance: Firefox's static update_url cannot apply here
// because every FC install is a different host, so the extension asks its
// configured backend — which means switching channel is repointing apiUrl in
// options and reinstalling from that host. There is no separate channel
// setting to build, and building one would contradict each server build
// shipping its own extension.
function versionIsNewer(candidate, current) {
// Dotted numeric compare so 1.0.10 > 1.0.9 (a plain string compare wouldn't).
@@ -60,12 +69,22 @@ async function checkForUpdateInfo() {
}
const currentVersion = browser.runtime.getManifest().version;
const latestVersion = info && info.version ? info.version : null;
// Which channel the configured instance serves — reported ALONGSIDE the
// version, never folded into it. A `-dev` suffix would have to survive
// versionIsNewer's parseInt above, and it wouldn't: the segment would read
// as 0 and every dev build would compare equal to every other.
//
// null is a normal answer, not a failure — an instance built before the
// field existed, or one built locally with no channel declared. Nothing
// below branches on it except the label.
const channel = info && info.channel ? info.channel : null;
// latest_url is served from the web root, not the JSON API.
const base = api.webRoot();
return {
updateAvailable: !!latestVersion && versionIsNewer(latestVersion, currentVersion),
currentVersion,
latestVersion,
channel,
xpiUrl: info && info.latest_url ? `${base}${info.latest_url}` : null,
};
}
@@ -77,7 +96,11 @@ async function refreshUpdateBadge() {
await browser.action.setBadgeText({ text: r.updateAvailable ? '↑' : '' });
if (r.updateAvailable) {
await browser.action.setBadgeBackgroundColor({ color: '#F4BA7A' });
await browser.action.setTitle({ title: `FabledCurator — update available (v${r.latestVersion})` });
// Channel first, version second, and the channel dropped entirely when
// the instance doesn't report one — so the tooltip reads exactly as it
// did before the field existed rather than saying "(unknown ...)".
const label = r.channel ? `${r.channel} v${r.latestVersion}` : `v${r.latestVersion}`;
await browser.action.setTitle({ title: `FabledCurator — update available (${label})` });
} else {
await browser.action.setTitle({ title: 'FabledCurator' });
}
+5 -1
View File
@@ -81,8 +81,12 @@ async function checkForUpdate() {
}
function showUpdateBanner(r) {
// The channel names itself beside the version, never inside it (#3113).
// Absent when the instance doesn't report one, and the banner then reads
// exactly as it did before the field existed.
const channel = r.channel ? ` (${r.channel})` : '';
document.getElementById('update-text').textContent =
`Update available — v${r.latestVersion} (installed v${r.currentVersion})`;
`Update available${channel} — v${r.latestVersion} (installed v${r.currentVersion})`;
// Opening the signed XPI triggers Firefox's native install prompt.
document.getElementById('update-btn').addEventListener('click', () => {
browser.tabs.create({ url: r.xpiUrl });
+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 ci = readText('..', '.forgejo', 'workflows', 'ci.yml')
expect(ci).toContain('extension/scripts/packaging.sh pathspec')
// A literal :(exclude)extension/... in the workflow means someone bypassed
// the shared definition.
expect(ci).not.toMatch(/:\(exclude\)extension\//)
const WORKFLOWS = ['ci.yml', 'build.yml', 'extension.yml']
it('no workflow hardcodes the packaged-file set', () => {
// ci.yml used to substitute `packaging.sh pathspec` directly, for the
// manual-bump guard that milestone 271 step 5 retired. Nothing inlines the
// set today, and nothing should start to: a literal :(exclude)extension/...
// in a workflow means someone bypassed the shared definition, which is
// exactly the drift #2397 was about. 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', () => {
it('keeps manifest.json and package.json in lockstep', () => {
expect(read('manifest.json').version).toBe(read('package.json').version)
const majorMinor = (v) => v.split('.').slice(0, 2).join('.')
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', () => {
// 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+)*$/)
})
@@ -4,6 +4,16 @@
<span v-if="manifest?.installed" class="text-caption fc-muted">
· Firefox · v{{ manifest.version }}
</span>
<!-- Which channel this instance serves, so it is visible without
installing anything. Rendered only when the image declares one: a
locally-built image, or one predating the field, says nothing rather
than guessing. Never merged into the version string beside it see
the endpoint's note on why a `-dev` suffix breaks the comparator. -->
<v-chip
v-if="manifest?.channel"
size="x-small" variant="tonal" class="ml-2"
:color="manifest.channel === 'dev' ? 'warning' : 'info'"
>{{ manifest.channel }}</v-chip>
</CardHeading>
<v-card-text>
@@ -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()
@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> -----------------------------------------