diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index e5ffbd6..f63df7d 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -49,36 +49,104 @@ jobs: steps: - uses: actions/checkout@v4 with: - # Full history: the shadow-mode step below derives a version from a - # commit count, which a depth-1 clone cannot produce. Harmless for - # everything else in this job. + # 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 - - 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 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 "Resolved extension version: $VERSION" + echo "Derived extension version: $VERSION" - # --- shadow mode (milestone #271, step 2) --------------------------- - # Informational ONLY — nothing downstream reads this, and it must never - # fail the build. This is THE place the derived formula gets validated: - # `sign-extension` only runs on main, so main pushes are the sole source - # of truth for whether the derived version moves exactly when the shipped - # extension changes. Compare these lines across several main builds - # before step 4 lets the derived value control publishing. - - name: Shadow — derived version (informational) + # 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: | - set -u - DERIVED=$(sh extension/scripts/packaging.sh version 2>&1 || echo "UNAVAILABLE") - MANUAL=${{ steps.extver.outputs.version }} - echo "shadow: manual=$MANUAL derived=$DERIVED sha=$GITHUB_SHA" - if [ "$MANUAL" = "$DERIVED" ]; then - echo "shadow: manual and derived agree" - else - echo "shadow: DIVERGENT — expected until step 4 cuts over; derived is authoritative-to-be" - fi + 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 @@ -116,6 +184,29 @@ jobs: # 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: | @@ -199,6 +290,12 @@ jobs: 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 (main + tags) # Fires on main-push AND on tag-push. Tag-push builds re-package the @@ -221,7 +318,13 @@ jobs: TOKEN: ${{ secrets.RELEASE_TOKEN }} run: | 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-, 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. diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index e9514a7..71e5caa 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -77,23 +77,6 @@ jobs: 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; } - # --- 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` # reads manifest.json (package.json sits in --ignore-files and isn't # even inside the XPI), so AMO signs MAN and Firefox installs MAN.