name: Build images on: push: # `:dev` builds were dropped 2026-05-26 to save a docker build per dev # push, on the reasoning that "operator tests from `:latest` after # merge-to-main". Restored 2026-08-27: that is testing by shipping, and # 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. # `:v26.05.26.5`) — gives a real rollback story alongside the floating # `:main` / `:latest`. Layer reuse keeps the registry-storage cost # negligible per tag. Doesn't overlap with the push-to-main build (that # one publishes `:main` + `:latest`; the tag-push build publishes only # `:`). tags: ['v*'] # Requires repo secret RELEASE_TOKEN — a Forgejo PAT with scopes: # - write:package, read:package (for docker push to git.fabledsword.com) # - write:release (for ext- release asset cache) # - write:issue (for future issue-management automation) # The injected GITHUB_TOKEN cannot be used — it lacks write:package. jobs: # Sign-or-fetch-from-cache: signs the extension via AMO if no ext- # Forgejo release exists yet, otherwise downloads the cached signed XPI. # Result is uploaded as an Actions artifact for build-web to consume. # # Why this lives in build.yml (not a separate workflow): the image a push # publishes MUST carry the XPI. A separate sign workflow racing build.yml # leaves that image without one for ~5min (until the commit-back triggers # another build). Inline ordering eliminates the race. # Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic # Packages (cleaner API surface) and commit-back-to-side-branch (no extra # branch to manage). AMO blocks re-signing the same version (returns 409), # 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- 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: if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 with: # Full history is load-bearing, not a convenience: the version this # job signs is derived from the commit TIME of the newest packaged # 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 # 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 run: | set -eu VERSION=$(sh extension/scripts/packaging.sh version) echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Derived extension version: $VERSION" # Firefox refuses a downgrade and AMO never releases a burned version, # so a version that moves BACKWARDS is unrecoverable: it strands every # install that already took the higher one. Two ways it could happen — # a checkout without full history (derives too low), or a rewritten # history that drops the newest packaged commit. # # The test is `derived < highest already signed`, strictly. Equality is # the ORDINARY case, not a fault: an unchanged extension derives the same # version it did last build, which is exactly what lets the ext- # 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: | python3 - <<'PY' import json, os, sys, urllib.request API = ("https://git.fabledsword.com/api/v1/repos/" "bvandeusen/FabledCurator/releases") headers = {"Authorization": "token " + os.environ["TOKEN"]} # Paginated rather than first-page-only: ext-* releases share this # 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 id: cache env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -eu VERSION=${{ steps.extver.outputs.version }} STATUS=$(curl -s -o release.json -w "%{http_code}" \ -H "Authorization: token $TOKEN" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) echo "Tag lookup HTTP status: $STATUS" # JSON parsing via python (ci-python:3.14 has stdlib json; jq is # not in the image and adding it per ci-requirements.md is not # warranted for a single consumer — operator-flagged 2026-05-26 # after a sign job failed with `jq: not found`). if [ "$STATUS" = "200" ]; then ASSET_ID=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['id'] if xpis else '')") if [ -n "$ASSET_ID" ]; then echo "cached=true" >> "$GITHUB_OUTPUT" echo "asset_id=$ASSET_ID" >> "$GITHUB_OUTPUT" echo "Cached XPI exists at ext-$VERSION (asset id $ASSET_ID); skipping AMO sign" else echo "cached=false" >> "$GITHUB_OUTPUT" echo "Release ext-$VERSION exists but has no .xpi asset; will re-sign + re-upload" fi else echo "cached=false" >> "$GITHUB_OUTPUT" echo "No release named ext-$VERSION; will sign via AMO and upload" fi # No "download cached XPI in sign-extension" step: build-web # fetches directly from the Forgejo ext- release asset # (removed 2026-05-26 alongside the actions/upload-artifact # removal — sign-extension's job is just to ensure the cache # 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) if: steps.cache.outputs.cached != 'true' run: | cd extension && npm install --no-save --no-audit --no-fund && npm run sign env: WEB_EXT_API_KEY: ${{ secrets.MOZILLA_AMO_JWT_KEY }} WEB_EXT_API_SECRET: ${{ secrets.MOZILLA_AMO_JWT_SECRET }} - name: Upload signed XPI to ext- release (cache miss) if: steps.cache.outputs.cached != 'true' env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -eux VERSION=${{ steps.extver.outputs.version }} # AMO renames signed XPIs with its internal addon-id-safe-string; # canonicalize to fabledcurator-.xpi so the FC server's # whitelist (backend/app/frontend.py expects 'fabledcurator-*.xpi') # keeps working. SIGNED=$(ls extension/web-ext-artifacts/*.xpi | head -1) XPI="extension/web-ext-artifacts/fabledcurator-$VERSION.xpi" cp "$SIGNED" "$XPI" # Find-or-create the ext- release. Track whether WE # created it so an upload failure below can roll back (don't # leave an empty release tombstone that the next run's # 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}" \ -H "Authorization: token $TOKEN" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) if [ "$STATUS" = "200" ]; then CREATED_BY_US=false else 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\":\"$GITHUB_SHA\"}" \ -o release.json \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases" CREATED_BY_US=true fi RELEASE_ID=$(python3 -c "import json; print(json.load(open('release.json'))['id'])") test -n "$RELEASE_ID" # Rollback-on-failure: if the asset upload fails AND we just # created the release in this run, delete it. Prevents an empty # ext- release from poisoning the next workflow run # (operator-flagged 2026-05-26 — without rollback the next run # saw 'release exists, no asset → cache miss → sign' which AMO # then rejected with 409 'Version already exists'). rollback_if_we_created() { if [ "$CREATED_BY_US" = "true" ]; then echo "Rolling back: deleting just-created release $RELEASE_ID" curl -s -X DELETE -H "Authorization: token $TOKEN" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID" || true curl -s -X DELETE -H "Authorization: token $TOKEN" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/tags/ext-$VERSION" || true fi } trap 'rollback_if_we_created' EXIT HTTP_CODE=$(curl -s -X POST -H "Authorization: token $TOKEN" \ -F "attachment=@$XPI" \ -o /dev/null -w "%{http_code}" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/$RELEASE_ID/assets?name=fabledcurator-$VERSION.xpi") if [ "$HTTP_CODE" != "201" ] && [ "$HTTP_CODE" != "200" ]; then echo "Asset upload failed with HTTP $HTTP_CODE" exit 1 fi # Upload succeeded — clear the rollback trap. trap - EXIT echo "Uploaded fabledcurator-$VERSION.xpi to ext-$VERSION release" # No actions/upload-artifact step: Forgejo Actions (and our # act_runner) doesn't support upload-artifact@v4+ (GHES limitation # surfaced 2026-05-26). Instead build-web reads the signed XPI # straight from the ext- Forgejo release we just uploaded # to. Same source of truth; no double-store. build-web: needs: [sign-extension] # 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') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - 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 # Fires on every trigger shape. dev and main each bundle the XPI their # own sign-extension just published — that is the whole point of the # channel work (milestone 271 step 6): the dev image carries the # 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 # v26.05.27.0 hit it): a release cut fires BOTH workflows almost # simultaneously. Main-push runs sign-extension (1-5min AMO round # trip) before publishing the ext- release; tag-push # skips sign-extension (gated to main) and races straight to # this download step. Tag-push lost every time. Fix: poll the # ext- release endpoint with a sleep+retry loop (30s # for up to 10min total) before giving up. Main-push's signing # eventually wins and tag-push picks the release up on a later # iteration. if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/') env: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | set -eux # Re-derived, not read from the repo: sign-extension published # ext-, 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- release. main-push's sign-extension # step (AMO round-trip, 1-5min) needs to finish + upload before # tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail. for attempt in $(seq 1 20); do STATUS=$(curl -s -o release.json -w "%{http_code}" \ -H "Authorization: token $TOKEN" \ "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) if [ "$STATUS" = "200" ]; then echo "Found ext-$VERSION release on attempt $attempt" break fi if [ "$attempt" = "20" ]; then echo "ERROR: ext-$VERSION release not available after 10min of polling" echo "Last HTTP status: $STATUS" exit 1 fi echo "Attempt $attempt: ext-$VERSION not yet published (HTTP $STATUS); sleeping 30s" sleep 30 done # Extract the .xpi asset's browser_download_url (Forgejo's # /releases/assets/ endpoint returns ASSET METADATA, not # the binary blob — operator-flagged 2026-05-26: my prior # code curl'd the metadata endpoint without -f and wrote the # resulting 404-page-not-found text into fabledcurator-*.xpi, # which Firefox then rejected as "corrupt"). # browser_download_url is the canonical binary endpoint and # is also publicly accessible (no token needed) but we pass # the token anyway for symmetry with private-repo support. DOWNLOAD_URL=$(python3 -c "import json; r=json.load(open('release.json')); xpis=[a for a in r.get('assets', []) if a.get('name','').endswith('.xpi')]; print(xpis[0]['browser_download_url'])") test -n "$DOWNLOAD_URL" echo "Downloading XPI from: $DOWNLOAD_URL" mkdir -p frontend/public/extension DEST="frontend/public/extension/fabledcurator-$VERSION.xpi" # -f = fail on HTTP error (prevents silent corruption like the # 2026-05-26 incident); -L = follow redirects. curl -sfL -H "Authorization: token $TOKEN" -o "$DEST" "$DOWNLOAD_URL" # Sanity check: the binary should start with the ZIP magic (PK\x03\x04). # If it's anything else, the next docker build will ship a corrupt XPI. MAGIC=$(head -c 2 "$DEST" | od -An -c | tr -d ' \n') if [ "$MAGIC" != "PK" ]; then echo "ERROR: downloaded XPI does not start with ZIP magic 'PK' (got '$MAGIC')" echo "File contents preview:" head -c 200 "$DEST" exit 1 fi cp "$DEST" "frontend/public/extension/fabledcurator-latest.xpi" ls -la frontend/public/extension/ - name: Determine tag id: tag run: | # Three trigger shapes: # refs/tags/v… → tag-push: opt-in milestone label (vYY.MM.DD, # no `.N` per family release-posture rule). # Publish ONLY the immutable version tag; # don't touch :latest (the main-push build # for the merge commit already did that). # refs/heads/main → push to main: publish :main + :latest # (floating) AND :c- (immutable # per-commit rollback substrate, per family # release-posture rule "Tags are milestones, # not gates — commit-SHA images are the # rollback unit"). Rollback to any commit # becomes `docker pull …:c-` without a # release ceremony. # refs/heads/dev → push to dev: publish :dev, the rolling test # channel (family rule 146). Rolling means it may # carry newer contents than the :c- of the # same commit; it never writes :c- itself, # because that is the rollback unit (rule 145). # POSIX-safe substring (the runner shell is dash/BusyBox sh, not # bash — `${var:0:7}` errors with "Bad substitution"; cut works # everywhere). Operator-flagged 2026-06-01 after first :c- # 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 uses: docker/login-action@v3 with: registry: git.fabledsword.com username: ${{ github.actor }} password: ${{ secrets.RELEASE_TOKEN }} - name: Build and push web image uses: docker/build-push-action@v5 with: context: . 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 container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 - name: Determine tag id: tag run: | # Mirrors build-web's three-shape logic (tag-push / main-push / # safety-net dev) including the per-commit :c- tag # on main-push per the family release-posture rule. The -ml # image follows the same release cadence as the web image. # POSIX-safe substring (the runner shell is dash/BusyBox sh, not # bash — `${var:0:7}` errors with "Bad substitution"; cut works # everywhere). Operator-flagged 2026-06-01 after first :c- # main-push build failed at this step. SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7) if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then TAG_NAME="${GITHUB_REF#refs/tags/}" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:${TAG_NAME}" >> "$GITHUB_OUTPUT" elif [ "${GITHUB_REF##*/}" = "main" ]; then echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:main,git.fabledsword.com/bvandeusen/fabledcurator-ml:latest,git.fabledsword.com/bvandeusen/fabledcurator-ml:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT" else echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-ml:dev" >> "$GITHUB_OUTPUT" fi - name: Login to Forgejo registry uses: docker/login-action@v3 with: registry: git.fabledsword.com username: ${{ github.actor }} password: ${{ secrets.RELEASE_TOKEN }} - name: Build and push ml image uses: docker/build-push-action@v5 with: context: . file: Dockerfile.ml push: true tags: ${{ steps.tag.outputs.tags }} # The desktop GPU agent (#114) — published so the operator pulls + runs it on # the GPU machine instead of building locally. Independent of web/ml (its own # CUDA + onnxruntime-gpu image, context = agent/). Same tag cadence. build-agent: runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 - name: Determine tag id: tag run: | SHORT_SHA=$(printf '%s' "$GITHUB_SHA" | cut -c1-7) if [ "${GITHUB_REF#refs/tags/}" != "${GITHUB_REF}" ]; then TAG_NAME="${GITHUB_REF#refs/tags/}" echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:${TAG_NAME}" >> "$GITHUB_OUTPUT" elif [ "${GITHUB_REF##*/}" = "main" ]; then echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:main,git.fabledsword.com/bvandeusen/fabledcurator-agent:latest,git.fabledsword.com/bvandeusen/fabledcurator-agent:c-${SHORT_SHA}" >> "$GITHUB_OUTPUT" else echo "tags=git.fabledsword.com/bvandeusen/fabledcurator-agent:dev" >> "$GITHUB_OUTPUT" fi - name: Login to Forgejo registry uses: docker/login-action@v3 with: registry: git.fabledsword.com username: ${{ github.actor }} password: ${{ secrets.RELEASE_TOKEN }} - name: Build and push agent image uses: docker/build-push-action@v5 with: context: agent file: agent/Dockerfile push: true tags: ${{ steps.tag.outputs.tags }}