ci(extension): the derived version drives signing (milestone 271 step 4)
Build images / sign-extension (push) Skipped
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 30s
extension / lint (push) Successful in 27s
Build images / build-web (push) Successful in 2m8s
Build images / build-ml (push) Successful in 2m48s
CI / integration (push) Successful in 3m52s
Build images / sign-extension (push) Skipped
CI / lint (push) Successful in 3s
CI / extension-version (push) Successful in 3s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 30s
extension / lint (push) Successful in 27s
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.
This commit is contained in:
+127
-24
@@ -49,36 +49,104 @@ jobs:
|
|||||||
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 +184,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: |
|
||||||
@@ -199,6 +290,12 @@ jobs:
|
|||||||
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 (main + tags)
|
||||||
# Fires on main-push AND on tag-push. Tag-push builds re-package the
|
# Fires on main-push AND on tag-push. Tag-push builds re-package the
|
||||||
@@ -221,7 +318,13 @@ jobs:
|
|||||||
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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user