Compare commits

...
Author SHA1 Message Date
bvandeusen 9eb946b21b ci(extension): sign on dev too, and bundle the XPI into :dev (step 6)
CI / lint (push) Successful in 4s
CI / extension-version (push) Successful in 4s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-ml (push) Successful in 2m40s
CI / integration (push) Successful in 3m55s
Build images / sign-extension (push) Successful in 4m43s
Build images / build-web (push) Successful in 2m11s
Build images / build-agent (push) Successful in 10m13s
The step the milestone exists for. sign-extension ungates from main-only
to main-or-dev, and build-web downloads the XPI on dev as well, so a dev
push produces an image carrying the extension that is being developed
rather than requiring a merge to try one.

Not two signatures. The version is the commit TIME of the newest packaged
extension change, so dev and main derive the SAME number for the same
source. A dev push that changes the extension signs it; the merge to main
finds the ext-<version> release already there, hits the cache, and bundles
the byte-identical XPI into :latest with no second AMO call. One signature
per extension CHANGE, shared by both channels. That property is what makes
two channels affordable at all, and it is why step 4 had to land first:
ungating this while the version was still the hand-set 1.0.11 would have
found the existing ext-1.0.11 release, skipped AMO, and bundled main's
stale XPI into :dev — a dev channel confidently serving old code.

Tags stay excluded. The tag path deliberately skips signing and polls for
the release instead (the 2026-05-27 race).

The ext-<version> release's target_commitish moves from the literal "main"
to $GITHUB_SHA. Either branch can create that release now, and tagging a
dev-signed XPI against a main commit that need not even contain the source
it was built from is a lie that costs nothing to avoid.

Known, not addressed here: two concurrent builds that both derive the same
unsigned version will both call AMO and the loser gets a 409. The window
already existed between main and tag pushes; dev signing widens it. It
fails loudly rather than shipping anything wrong, and the rollback trap
cleans up the empty release. Filed separately.

Also unchanged here: ci.yml's manual-bump guard is still in place and
still false. It does not fire on this commit — nothing packaged changed —
but it will fail the lane on the next extension change, demanding a bump
that no longer decides anything. Step 5 next.
2026-08-27 10:56:58 -04:00
bvandeusen 5447a40e97 ci(extension): the derived version drives signing (milestone 271 step 4)
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
CI / backend-lint-and-test (push) Successful in 30s
extension / lint (push) Successful in 27s
Build images / sign-extension (push) Skipped
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
Build images / build-web (push) Successful in 2m8s
Build images / build-ml (push) Successful in 2m48s
CI / integration (push) Successful in 3m52s
Cutover. sign-extension no longer reads the version out of the repo — it
runs packaging.sh version and stamps the result into manifest.json and
package.json in the working tree before web-ext sees them. Never
committed back: the commit carrying the bump would itself be a change to
the extension and would move the version again.

Shadow mode ends here, in both build.yml and ci.yml. It had one job —
validate the formula at zero cost before a real AMO version was burned —
and CI confirmed it on 239b1ed: shadow: manual=1.0.11 derived=1.0.3499884.

build-web re-derives rather than being handed the value, so it gains
fetch-depth: 0. It was the outstanding landmine: a depth-1 clone derives a
WRONG, too-low version rather than failing, and would then 404 fetching a
release that exists under its real name. sign-extension and
extension-version already had full history.

New guard, and it stays permanently: refuse to sign when the derived
version is strictly OLDER than the highest ext-* release already signed.
Firefox rejects a downgrade and AMO never releases a burned version, so
backwards is unrecoverable — it strands every install that took the higher
one. Strictly older, not older-or-equal: equality is the ordinary case,
an unchanged extension deriving the same version it did last build, which
is exactly what makes the ext-<version> cache hit and holds AMO to one
call per extension CHANGE rather than per push. The release list is
paginated because ext-* shares it with the v* tags, and the bound fails
rather than calling the highest it happened to see the highest there is.

First derived value is 1.0.3499884 against a highest-signed ext-1.0.10, so
the backfill direction is right by six orders of magnitude. 1.0.11 sits in
the repo and was never signed; nothing is stranded by skipping past it.

Still main-only. Step 6 ungates sign-extension to dev, which is what
actually puts an XPI on :dev.

Note for step 5: ci.yml's manual-bump guard is now false. It still demands
a hand bump when a packaged file changes, and that bump no longer decides
anything — the derived value overwrites it at build time. Harmless but
pointless, and it should be retired before the next extension change.
2026-08-27 10:45:17 -04:00
2 changed files with 167 additions and 55 deletions
+167 -38
View File
@@ -33,52 +33,135 @@ jobs:
# Forgejo release exists yet, otherwise downloads the cached signed XPI. # Forgejo release exists yet, otherwise downloads the cached signed XPI.
# Result is uploaded as an Actions artifact for build-web to consume. # Result is uploaded as an Actions artifact for build-web to consume.
# #
# Why this lives in build.yml (not a separate workflow): the merge-commit's # Why this lives in build.yml (not a separate workflow): the image a push
# docker image tagged `:latest` MUST carry the XPI. A separate sign workflow # publishes MUST carry the XPI. A separate sign workflow racing build.yml
# racing build.yml leaves `:latest` without the XPI for ~5min (until the # leaves that image without one for ~5min (until the commit-back triggers
# commit-back triggers another build). Inline ordering eliminates the race. # another build). Inline ordering eliminates the race.
# Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic # Cache strategy: Forgejo Release Assets — picked 2026-05-25 over Generic
# Packages (cleaner API surface) and commit-back-to-side-branch (no extra # Packages (cleaner API surface) and commit-back-to-side-branch (no extra
# branch to manage). AMO blocks re-signing the same version (returns 409), # branch to manage). AMO blocks re-signing the same version (returns 409),
# so signing is intentionally one-shot per version bump. # so signing is intentionally one-shot per version.
#
# BOTH branches sign (milestone 271 step 6, 2026-08-27). Not two signatures:
# the version is the commit TIME of the newest packaged-extension change, so
# dev and main derive the SAME number for the same extension source. A dev
# push that changes the extension signs it; the merge to main then finds the
# ext-<version> release already there, hits the cache, and bundles the
# byte-identical XPI into `:latest` with no second AMO call. One signature
# per extension CHANGE, shared by both channels — that is what makes two
# channels affordable, and it is why step 4 (derived version) had to land
# first. Ungating this while the version was still the hand-set 1.0.11 would
# have hit the existing ext-1.0.11 cache and bundled MAIN's stale XPI into
# `:dev` — a dev channel confidently serving old code.
#
# Tags stay excluded: the tag path deliberately skips signing and polls for
# the release instead (see build-web's race note, 2026-05-27).
sign-extension: sign-extension:
if: github.ref == 'refs/heads/main' if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev'
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
# Full history: the shadow-mode step below derives a version from a # Full history is load-bearing, not a convenience: the version this
# commit count, which a depth-1 clone cannot produce. Harmless for # job signs is derived from the commit TIME of the newest packaged
# everything else in this job. # extension change. A depth-1 clone sees one commit and derives a
# wrong, too-low value rather than failing (ci-requirements.md).
fetch-depth: 0 fetch-depth: 0
- name: Resolve extension version # The version is DERIVED, not read from the repo (milestone 271 step 4,
# cut over 2026-08-27). `packaging.sh version` returns MAJOR.MINOR from
# manifest.json plus a patch component that is the commit TIME of the
# newest change to a PACKAGED extension file, in minutes since
# 2020-01-01 — family rule 149, never a commit count, which orders by
# branch rather than by recency.
#
# The committed "version" in manifest.json / package.json no longer
# decides anything: the stamp step below overwrites it in the working
# tree before web-ext ever reads it. It is deliberately NOT committed
# back — the commit carrying the bump would itself be a change to the
# extension and would move the version again. The repo holds the source;
# the build derives the label.
- name: Derive extension version
id: extver id: extver
run: | run: |
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') set -eu
VERSION=$(sh extension/scripts/packaging.sh version)
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Resolved extension version: $VERSION" echo "Derived extension version: $VERSION"
# --- shadow mode (milestone #271, step 2) --------------------------- # Firefox refuses a downgrade and AMO never releases a burned version,
# Informational ONLY — nothing downstream reads this, and it must never # so a version that moves BACKWARDS is unrecoverable: it strands every
# fail the build. This is THE place the derived formula gets validated: # install that already took the higher one. Two ways it could happen —
# `sign-extension` only runs on main, so main pushes are the sole source # a checkout without full history (derives too low), or a rewritten
# of truth for whether the derived version moves exactly when the shipped # history that drops the newest packaged commit.
# extension changes. Compare these lines across several main builds #
# before step 4 lets the derived value control publishing. # The test is `derived < highest already signed`, strictly. Equality is
- name: Shadow — derived version (informational) # the ORDINARY case, not a fault: an unchanged extension derives the same
# version it did last build, which is exactly what lets the ext-<version>
# cache hit and holds AMO to one call per extension CHANGE. Only moving
# backwards is a failure, so this runs on every path — cache hit
# included — rather than only before a sign.
- name: Guard — the derived version must never go backwards
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
DERIVED: ${{ steps.extver.outputs.version }}
run: | run: |
set -u python3 - <<'PY'
DERIVED=$(sh extension/scripts/packaging.sh version 2>&1 || echo "UNAVAILABLE") import json, os, sys, urllib.request
MANUAL=${{ steps.extver.outputs.version }}
echo "shadow: manual=$MANUAL derived=$DERIVED sha=$GITHUB_SHA" API = ("https://git.fabledsword.com/api/v1/repos/"
if [ "$MANUAL" = "$DERIVED" ]; then "bvandeusen/FabledCurator/releases")
echo "shadow: manual and derived agree" headers = {"Authorization": "token " + os.environ["TOKEN"]}
else
echo "shadow: DIVERGENT — expected until step 4 cuts over; derived is authoritative-to-be" # Paginated rather than first-page-only: ext-* releases share this
fi # list with the v* release tags, so one page would start missing them
# as those accumulate. The bound FAILS rather than silently scanning
# part of the list and calling the highest it saw the highest there is.
tags = []
for page in range(1, 21):
req = urllib.request.Request(
f"{API}?limit=50&page={page}", headers=headers)
with urllib.request.urlopen(req, timeout=30) as resp:
batch = json.load(resp)
if not batch:
break
tags += [r.get("tag_name", "") for r in batch]
else:
sys.exit("guard: >1000 releases — pagination bound reached")
def parse(v):
try:
return tuple(int(part) for part in v.split("."))
except ValueError:
return None
derived_s = os.environ["DERIVED"]
derived = parse(derived_s)
if derived is None:
sys.exit(f"guard: derived version {derived_s!r} is not numeric")
signed = sorted(
(v, t) for t in tags if t.startswith("ext-")
for v in [parse(t[4:])] if v
)
if not signed:
print("guard: no ext-* release yet — nothing to go backwards from")
raise SystemExit(0)
hi, hi_tag = signed[-1]
print(f"guard: derived={derived_s} highest already signed={hi_tag}")
if derived < hi:
sys.exit(
f"REFUSING TO SIGN: derived {derived_s} is OLDER than the "
f"already-signed {hi_tag}. Firefox would reject it as a "
f"downgrade, and AMO will not release the burned version. "
f"First thing to check: did this job check out with "
f"fetch-depth: 0?"
)
print("guard: ok")
PY
- name: Check Forgejo release-asset cache - name: Check Forgejo release-asset cache
id: cache id: cache
@@ -116,6 +199,29 @@ jobs:
# removal — sign-extension's job is just to ensure the cache # removal — sign-extension's job is just to ensure the cache
# exists on Forgejo; the build-web side reads it independently). # exists on Forgejo; the build-web side reads it independently).
# web-ext signs whatever manifest.json says, so the derived value has to
# reach the tree before signing. package.json is written too: the two are
# required to agree (ci.yml's guard), and a local `npm run build` reads
# it. Working tree only — never committed, per the note on the derive
# step.
- name: Stamp the derived version into manifest.json + package.json
env:
DERIVED: ${{ steps.extver.outputs.version }}
run: |
python3 - <<'PY'
import json, os
version = os.environ["DERIVED"]
for path in ("extension/manifest.json", "extension/package.json"):
with open(path) as fh:
doc = json.load(fh)
doc["version"] = version
with open(path, "w") as fh:
json.dump(doc, fh, indent=2)
fh.write("\n")
print(f"{path}: version -> {version}")
PY
- name: Sign via AMO (cache miss) - name: Sign via AMO (cache miss)
if: steps.cache.outputs.cached != 'true' if: steps.cache.outputs.cached != 'true'
run: | run: |
@@ -142,6 +248,11 @@ jobs:
# created it so an upload failure below can roll back (don't # created it so an upload failure below can roll back (don't
# leave an empty release tombstone that the next run's # leave an empty release tombstone that the next run's
# cache-check mistakes for a partial-failure state). # cache-check mistakes for a partial-failure state).
#
# target_commitish is the signing commit, not a branch name: since
# step 6 either branch can create this release, and hard-coding
# `main` would tag a dev-signed XPI against a main commit that may
# not even contain the extension source it was built from.
STATUS=$(curl -s -o release.json -w "%{http_code}" \ STATUS=$(curl -s -o release.json -w "%{http_code}" \
-H "Authorization: token $TOKEN" \ -H "Authorization: token $TOKEN" \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000) "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases/tags/ext-$VERSION" || echo 000)
@@ -149,7 +260,7 @@ jobs:
CREATED_BY_US=false CREATED_BY_US=false
else else
curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ curl -s -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"main\"}" \ -d "{\"tag_name\":\"ext-$VERSION\",\"name\":\"Extension $VERSION (signed XPI cache)\",\"body\":\"Internal cache for the signed XPI consumed by build.yml's build-web job. Not a user-facing FC release.\",\"target_commitish\":\"$GITHUB_SHA\"}" \
-o release.json \ -o release.json \
"https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases" "https://git.fabledsword.com/api/v1/repos/bvandeusen/FabledCurator/releases"
CREATED_BY_US=true CREATED_BY_US=true
@@ -192,19 +303,31 @@ jobs:
build-web: build-web:
needs: [sign-extension] needs: [sign-extension]
# sign-extension is main-only; on dev it's skipped, build-web still runs. # sign-extension runs on main and dev, and is skipped on a tag push (which
# polls for the release instead). Either is fine to build on; a FAILED sign
# is not — this condition lets success and skipped through, so a failure
# skips build-web rather than shipping an image without the XPI.
if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped') if: always() && (needs.sign-extension.result == 'success' || needs.sign-extension.result == 'skipped')
runs-on: python-ci runs-on: python-ci
container: container:
image: git.fabledsword.com/bvandeusen/ci-python:3.14 image: git.fabledsword.com/bvandeusen/ci-python:3.14
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with:
# Full history: this job RE-DERIVES the extension version rather than
# being handed it, and a depth-1 clone derives a wrong, too-low value
# rather than failing — which would 404 the download of a release
# that exists perfectly well under its real name.
fetch-depth: 0
- name: Download signed XPI from Forgejo release asset (main + tags) - name: Download signed XPI from Forgejo release asset
# Fires on main-push AND on tag-push. Tag-push builds re-package the # Fires on every trigger shape. dev and main each bundle the XPI their
# same source code as the preceding main-push build but with an # own sign-extension just published — that is the whole point of the
# immutable version tag — they need the XPI too, otherwise the # channel work (milestone 271 step 6): the dev image carries the
# versioned image ships without the signed extension. # extension being developed, rather than requiring a merge to try it.
# Tag-push builds re-package the same source as the preceding main-push
# build but with an immutable version tag — they need the XPI too,
# otherwise the versioned image ships without the signed extension.
# #
# Tag-push vs main-push race (operator-flagged 2026-05-27 after # Tag-push vs main-push race (operator-flagged 2026-05-27 after
# v26.05.27.0 hit it): a release cut fires BOTH workflows almost # v26.05.27.0 hit it): a release cut fires BOTH workflows almost
@@ -216,12 +339,18 @@ jobs:
# for up to 10min total) before giving up. Main-push's signing # for up to 10min total) before giving up. Main-push's signing
# eventually wins and tag-push picks the release up on a later # eventually wins and tag-push picks the release up on a later
# iteration. # iteration.
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')
env: env:
TOKEN: ${{ secrets.RELEASE_TOKEN }} TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
set -eux set -eux
VERSION=$(grep -E '"version"' extension/package.json | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') # Re-derived, not read from the repo: sign-extension published
# ext-<derived>, and the committed version has been inert since
# milestone 271 step 4. Both jobs run `packaging.sh version` over the
# same commit, so they agree by construction — and if they ever
# didn't, this download 404s and the build fails loudly instead of
# shipping a stale XPI.
VERSION=$(sh extension/scripts/packaging.sh version)
# Poll for the ext-<version> release. main-push's sign-extension # Poll for the ext-<version> release. main-push's sign-extension
# step (AMO round-trip, 1-5min) needs to finish + upload before # step (AMO round-trip, 1-5min) needs to finish + upload before
# tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail. # tag-push can fetch. 30s * 20 = up to 10min wait, then hard-fail.
-17
View File
@@ -77,23 +77,6 @@ jobs:
test -n "$PKG" || { echo "ERROR: no version found in extension/package.json"; exit 1; } 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; } test -n "$MAN" || { echo "ERROR: no version found in extension/manifest.json"; exit 1; }
# --- shadow mode (milestone #271, step 2) -------------------------
# Informational ONLY: nothing below reads DERIVED, and this must never
# fail the job. `web-ext sign` is one-shot per version (AMO 409s on a
# repeat), so a wrong formula would burn a real version number that
# can't be reclaimed. Logging it against real pushes first is the only
# way to validate it at zero cost.
# Placed before every early-exit path so it reports on all runs.
#
# The formula CHANGED on 2026-08-27 (commit count -> commit time, per
# rule 149), so observations logged before that date describe the old
# one and prove nothing about this. The window restarts here. Unlike
# build.yml's copy this runs on dev too, so both channels' numbers are
# visible — which is the pair that has to stay ordered.
DERIVED=$(sh extension/scripts/packaging.sh version 2>&1 || echo "UNAVAILABLE")
echo "shadow: manual=$PKG derived=$DERIVED"
# -----------------------------------------------------------------
# (1) Unconditional: the two version strings must agree. `web-ext sign` # (1) Unconditional: the two version strings must agree. `web-ext sign`
# reads manifest.json (package.json sits in --ignore-files and isn't # reads manifest.json (package.json sits in --ignore-files and isn't
# even inside the XPI), so AMO signs MAN and Firefox installs MAN. # even inside the XPI), so AMO signs MAN and Firefox installs MAN.