Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7e626a67a | ||
|
|
fe48e77821 |
@@ -426,13 +426,20 @@ jobs:
|
|||||||
# 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
|
||||||
@@ -449,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
-95
@@ -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,97 +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."
|
||||||
# (1) Unconditional: the two version strings must agree. `web-ext sign`
|
exit 1
|
||||||
# reads manifest.json (package.json sits in --ignore-files and isn't
|
fi
|
||||||
# even inside the XPI), so AMO signs MAN and Firefox installs MAN.
|
mm() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+\.[0-9]+).*/\1/'; }
|
||||||
# build.yml keys its cache, release tag, XPI filename — and therefore
|
MAN=$(mm extension/manifest.json)
|
||||||
# the version /api/extension/manifest reports to the update prompt —
|
PKG=$(mm extension/package.json)
|
||||||
# on PKG. Divergence either hard-fails at AMO or ships a mislabelled
|
test -n "$MAN" || { echo "ERROR: no parseable version in extension/manifest.json"; exit 1; }
|
||||||
# XPI whose update prompt lies about what's installed.
|
test -n "$PKG" || { echo "ERROR: no parseable version in extension/package.json"; exit 1; }
|
||||||
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
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
|
|||||||
@@ -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
-11
@@ -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
|
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 `git log` pathspec
|
pathspec inside the script's own version derivation. It was three until
|
||||||
that derives the extension version. Three hand-kept copies of that one fact
|
2026-08-27 — `ci.yml`'s `extension-version` guard held the third and went when
|
||||||
is what allowed issue #2397.
|
the manual bump it guarded did (milestone 271 step 5). Hand-kept copies of
|
||||||
- Jobs that derive the extension version check out with `fetch-depth: 0`. The
|
that one fact is what allowed issue #2397, so `extension/test/version.spec.js`
|
||||||
version is the commit TIME of the newest packaged-extension change (minutes
|
asserts no workflow has reintroduced a literal `:(exclude)extension/…`.
|
||||||
since 2020-01-01, per family rule 149 — never a commit count, which orders
|
- **The shipped extension version is derived, not committed.** It is the commit
|
||||||
by branch rather than by recency). A depth-1 clone sees one commit and
|
TIME of the newest packaged-extension change (minutes since 2020-01-01, per
|
||||||
derives a wrong, too-low value rather than failing, so the full-history
|
family rule 149 — never a commit count, which orders by branch rather than by
|
||||||
checkout is load-bearing wherever `packaging.sh version` is called.
|
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
@@ -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.
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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> -----------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user