Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9eb946b21b | ||
|
|
5447a40e97 | ||
|
|
239b1ed8d9 | ||
|
|
cd5444e3ae |
+184
-45
@@ -2,10 +2,18 @@ name: Build images
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
# `:dev` builds dropped 2026-05-26 — operator tests from `:latest` after
|
# `:dev` builds were dropped 2026-05-26 to save a docker build per dev
|
||||||
# merge-to-main, not from the dev branch image. Saves one full docker
|
# push, on the reasoning that "operator tests from `:latest` after
|
||||||
# build per dev push.
|
# merge-to-main". Restored 2026-08-27: that is testing by shipping, and
|
||||||
branches: [main]
|
# 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.
|
# Tag-push triggers an immutable per-version image build (e.g.
|
||||||
# `:v26.05.26.5`) — gives a real rollback story alongside the floating
|
# `:v26.05.26.5`) — gives a real rollback story alongside the floating
|
||||||
# `:main` / `:latest`. Layer reuse keeps the registry-storage cost
|
# `:main` / `:latest`. Layer reuse keeps the registry-storage cost
|
||||||
@@ -25,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
|
||||||
@@ -108,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: |
|
||||||
@@ -134,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)
|
||||||
@@ -141,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
|
||||||
@@ -184,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
|
||||||
@@ -208,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.
|
||||||
@@ -279,9 +416,11 @@ jobs:
|
|||||||
# rollback unit"). Rollback to any commit
|
# rollback unit"). Rollback to any commit
|
||||||
# becomes `docker pull …:c-<sha>` without a
|
# becomes `docker pull …:c-<sha>` without a
|
||||||
# release ceremony.
|
# release ceremony.
|
||||||
# anything else → safety net; shouldn't fire given the `on:`
|
# refs/heads/dev → push to dev: publish :dev, the rolling test
|
||||||
# config above. Tag :dev to surface the
|
# channel (family rule 146). Rolling means it may
|
||||||
# unexpected run in the registry.
|
# carry newer contents than the :c-<sha> of the
|
||||||
|
# same commit; it never writes :c-<sha> itself,
|
||||||
|
# because that is the rollback unit (rule 145).
|
||||||
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not
|
# POSIX-safe substring (the runner shell is dash/BusyBox sh, not
|
||||||
# bash — `${var:0:7}` errors with "Bad substitution"; cut works
|
# bash — `${var:0:7}` errors with "Bad substitution"; cut works
|
||||||
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
|
# everywhere). Operator-flagged 2026-06-01 after first :c-<sha>
|
||||||
|
|||||||
@@ -77,17 +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.
|
|
||||||
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.
|
||||||
|
|||||||
+9
-5
@@ -56,11 +56,15 @@ per `docs/process.md`'s "add deps to the image when used by >1 project".
|
|||||||
- **`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.** Three 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`), the `:(exclude)`
|
||||||
pathspec in `ci.yml`'s `extension-version` guard, and the commit count that
|
pathspec in `ci.yml`'s `extension-version` guard, and the `git log` pathspec
|
||||||
derives the extension version. Three hand-kept copies of that one fact is
|
that derives the extension version. Three hand-kept copies of that one fact
|
||||||
what allowed issue #2397.
|
is what allowed issue #2397.
|
||||||
- `build.yml`'s `sign-extension` checks out with `fetch-depth: 0` — the derived
|
- Jobs that derive the extension version check out with `fetch-depth: 0`. The
|
||||||
extension version is a commit count, which a shallow clone cannot produce.
|
version is the commit TIME of the newest packaged-extension change (minutes
|
||||||
|
since 2020-01-01, per family rule 149 — never a commit count, which orders
|
||||||
|
by branch rather than by recency). 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 wherever `packaging.sh version` is called.
|
||||||
- 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
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
#
|
#
|
||||||
# 1. web-ext's --ignore-files (extension/package.json's four scripts)
|
# 1. web-ext's --ignore-files (extension/package.json's four scripts)
|
||||||
# 2. the :(exclude) pathspec (ci.yml's extension-version guard)
|
# 2. the :(exclude) pathspec (ci.yml's extension-version guard)
|
||||||
# 3. the rev-list pathspec (the derived version, below)
|
# 3. the git-log pathspec (the derived version, below)
|
||||||
#
|
#
|
||||||
# They now all read from here. POSIX sh only — CI's run shell is busybox.
|
# They now all read from here. POSIX sh only — CI's run shell is busybox.
|
||||||
#
|
#
|
||||||
@@ -68,20 +68,51 @@ cmd_major_minor() {
|
|||||||
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+)\.([0-9]+).*/\1.\2/'
|
| sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([0-9]+)\.([0-9]+).*/\1.\2/'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Count of commits that touched a PACKAGED extension file. Monotonic on a
|
# 2020-01-01T00:00:00Z — the anchor for the derived patch component. Fixed
|
||||||
# branch (the count only grows), which is a correctness requirement, not a
|
# forever; moving it would renumber every version downwards.
|
||||||
# nicety: Firefox refuses to install a version lower than the one present.
|
VERSION_EPOCH=1577836800
|
||||||
|
|
||||||
|
# Minutes since VERSION_EPOCH of the LATEST commit that touched a PACKAGED
|
||||||
|
# extension file.
|
||||||
#
|
#
|
||||||
# Merge commits need no special handling — git's history simplification already
|
# Time-derived, per family rule 149: an artifact's ordering key must never be a
|
||||||
# prunes merges that don't change the pathspec, so --no-merges is a no-op here
|
# commit count. A count is per-branch — `dev` and `main` count different
|
||||||
# (verified on main: both forms return the same count).
|
# histories of the same code — so the moment BOTH channels publish, their
|
||||||
|
# versions order by which branch accumulated more commits rather than by which
|
||||||
|
# is newer. A squash-merge makes that permanent: main gains one commit where dev
|
||||||
|
# gained five, so dev climbs away from main and a dev install can never cross
|
||||||
|
# back. That is Roundtable's 2026-08-24 incident (`versionCode` was the branch's
|
||||||
|
# commit count) in a different repo. Measured here on 2026-08-27: main=23,
|
||||||
|
# dev=24 under the old formula — one apart, which is exactly how the inversion
|
||||||
|
# stays invisible until it strands somebody.
|
||||||
|
#
|
||||||
|
# Why the commit's time and not the build's:
|
||||||
|
# * MONOTONIC — max() over a set that only ever gains members. Verified
|
||||||
|
# across all 24 extension-touching commits: zero non-monotonic steps.
|
||||||
|
# * STABLE while the extension is unchanged, so an unchanged extension keeps
|
||||||
|
# its version, the ext-<version> signature cache still hits, and AMO is
|
||||||
|
# called once per extension CHANGE rather than once per push. Build-time
|
||||||
|
# minutes would re-sign on every push and never let two channels share a
|
||||||
|
# signature.
|
||||||
|
# * SHARED ACROSS CHANNELS — after a merge, `main` sees the same commit and
|
||||||
|
# derives the same number, so `:latest` reuses the signature `:dev` already
|
||||||
|
# produced for byte-identical code. Same code, same version, one signing.
|
||||||
|
# * REPRODUCIBLE — any checkout of a commit yields that commit's version.
|
||||||
|
#
|
||||||
|
# Requires real history: a depth-1 clone sees one commit and will derive a wrong
|
||||||
|
# (too low) value. Every consumer must check out with fetch-depth: 0.
|
||||||
cmd_patch() {
|
cmd_patch() {
|
||||||
root=$(git rev-parse --show-toplevel)
|
root=$(git rev-parse --show-toplevel)
|
||||||
# Unquoted on purpose: the pathspec must word-split into separate args.
|
# Unquoted on purpose: the pathspec must word-split into separate args.
|
||||||
# Globbing is already off script-wide (set -euf above).
|
# Globbing is already off script-wide (set -euf above).
|
||||||
# shellcheck disable=SC2046
|
# shellcheck disable=SC2046
|
||||||
count=$(cd "$root" && git rev-list --count HEAD -- extension/ $(cmd_pathspec))
|
ts=$(cd "$root" && git log --format=%ct HEAD -- extension/ $(cmd_pathspec) \
|
||||||
echo "$count"
|
| sort -n | tail -1)
|
||||||
|
if [ -z "$ts" ]; then
|
||||||
|
echo "packaging.sh: no commit touches a packaged extension file" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo $(( (ts - VERSION_EPOCH) / 60 ))
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_version() {
|
cmd_version() {
|
||||||
|
|||||||
Reference in New Issue
Block a user