From c37a180c3cba9f81f13eb64d7855d1e0c0c27578 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 20:20:44 -0400 Subject: [PATCH] ci: guard the extension publish path against a missed version bump build.yml's sign-extension keys its AMO-signing cache purely on the version string in extension/package.json. If an ext- release already has an XPI, signing is skipped and build-web bakes that OLD signed XPI into :latest. Nothing in that path inspects whether extension/ actually changed, so a forgotten bump ships a stale extension on a fully green build -- silently, and as the default outcome of forgetting. AMO can't backstop it either: it 409s on re-signing a version, which is precisely why the cache exists. New extension-version job, pure git + text, no deps or services: 1. Unconditional consistency check. manifest.json and package.json versions must match. web-ext sign reads manifest.json (package.json is in --ignore-files and isn't even inside the XPI), so AMO signs the manifest version; build.yml keys its cache, release tag, XPI filename -- and so the version /api/extension/manifest reports to the update prompt -- on package.json. Divergence either 409s at AMO or ships an XPI whose update prompt lies about what's installed. 2. Changed-without-bump check. If any PACKAGED file under extension/ differs, the version must have moved. Exclusions mirror --ignore-files so a Renovate web-ext devDep bump in package.json doesn't falsely demand one. Compared against main rather than the previous push: the publish decision is made at merge-to-main against whatever ext- exists, so "differs from main" is the question that matters. Diffing against the previous dev push would demand a fresh bump on every iteration, inflating the version to buy nothing. Bumping stays manual -- making it automatic requires rewriting the version in CI and committing back to a protected branch, which this workflow deliberately avoided. This only ensures a missed bump can no longer be silent. Refs #2393 Co-Authored-By: Claude Opus 5 (1M context) --- .forgejo/workflows/ci.yml | 111 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 4a0af72..184e9b3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI # CI lanes per FabledRulebook/forgejo.md "CI philosophy": # - lint: ruff only, no dep install — fast-fail for the common lint bounce. +# - extension-version: guards the extension publish path (see the job). # - backend-lint-and-test: `pytest -m "not integration"`, no service containers. # - frontend-build: vitest unit + vite build. # - integration: pgvector + redis service containers; alembic + `pytest -m integration`. @@ -41,6 +42,116 @@ jobs: # catching syntax errors before the image build. run: python -m compileall -q agent/fc_agent + # Guards the extension publish path, which has no self-correcting behavior. + # + # build.yml's sign-extension job keys its AMO-signing cache purely on the + # version string in extension/package.json: if an `ext-` Forgejo + # release already carries an XPI, signing is SKIPPED and that old signed XPI + # is what build-web bakes into `:latest`. Nothing in that path inspects + # whether extension/ actually changed — so a forgotten version bump ships a + # stale extension on a fully green build, silently. (AMO can't help: it 409s + # on re-signing a version, which is exactly why the cache exists.) + # + # This job makes that case loud, on the dev push, instead of invisible at + # merge-to-main. It is pure git + text work — no deps, no services. + extension-version: + runs-on: python-ci + container: + image: git.fabledsword.com/bvandeusen/ci-python:3.14 + steps: + - uses: actions/checkout@v4 + with: + # Full history: the check diffs against the push's `before` SHA (or + # the PR base), which a depth-1 clone wouldn't contain. + fetch-depth: 0 + - name: Extension version guard + env: + BEFORE: ${{ github.event.before }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + run: | + set -eu + # busybox sh on the act_runner — no bashisms (family rule). + ver() { grep -E '"version"' "$1" | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/'; } + PKG=$(ver extension/package.json) + MAN=$(ver extension/manifest.json) + 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; } + + # (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. + # build.yml keys its cache, release tag, XPI filename — and therefore + # the version /api/extension/manifest reports to the update prompt — + # on PKG. Divergence either hard-fails at AMO or ships a mislabelled + # XPI whose update prompt lies about what's installed. + if [ "$MAN" != "$PKG" ]; then + echo "ERROR: extension version mismatch." + echo " extension/manifest.json = $MAN <- what AMO signs / Firefox installs" + echo " extension/package.json = $PKG <- what CI caches, names, and reports" + echo "Set both to the same value." + exit 1 + fi + + # (2) If the SHIPPED extension changed, the version must have moved. + # + # Compare against MAIN, not against the previous push. The publish + # decision is made at merge-to-main against whatever ext- + # already exists, so "differs from main" is the question that matters. + # Diffing against the previous dev push instead would demand a fresh + # bump on every iteration — push, tweak the extension again, and CI + # would insist on a second bump that buys nothing, inflating the + # version for no reason. On a main push there is no "main to compare + # to" yet, so fall back to that push's own before-SHA. + if [ "${GITHUB_REF##*/}" = "main" ]; then + BASE="${BEFORE:-}" + else + BASE=$(git rev-parse --verify -q origin/main 2>/dev/null || git rev-parse --verify -q main 2>/dev/null || echo "") + # PR base is the fallback when main isn't in the clone at all. + [ -n "$BASE" ] || BASE="${PR_BASE:-}" + fi + case "$BASE" in + ''|0000000000000000000000000000000000000000) + echo "No usable base ref (no main in clone / first push) — skipping the bump check." + echo "OK: extension version $PKG" + exit 0 + ;; + esac + if ! git cat-file -e "$BASE^{commit}" 2>/dev/null; then + echo "Base commit $BASE not in this clone — skipping the bump check." + echo "OK: extension version $PKG" + exit 0 + fi + # Exclusions mirror --ignore-files in extension/package.json's web-ext + # scripts: these files are not packaged into the XPI, so touching them + # (e.g. Renovate bumping the web-ext devDep) changes nothing shipped + # and must not demand a version bump. + CHANGED=$(git diff --name-only "$BASE" HEAD -- extension/ \ + ':(exclude)extension/package.json' \ + ':(exclude)extension/package-lock.json' \ + ':(exclude)extension/README.md' \ + ':(exclude)extension/.gitignore') + if [ -z "$CHANGED" ]; then + echo "No packaged extension files changed since $BASE — nothing to guard." + echo "OK: extension version $PKG" + exit 0 + fi + echo "Packaged extension files changed since $BASE:" + echo "$CHANGED" | sed 's/^/ /' + PKG_OLD=$(git show "$BASE:extension/package.json" 2>/dev/null | grep -E '"version"' | head -1 | sed -E 's/.*"version"[[:space:]]*:[[:space:]]*"([^"]+)".*/\1/') + if [ -z "$PKG_OLD" ]; then + echo "Could not read the base version — skipping the bump check." + echo "OK: extension version $PKG" + exit 0 + fi + if [ "$PKG_OLD" = "$PKG" ]; then + echo "ERROR: packaged extension files changed but the version is still $PKG." + echo "build.yml would find the existing ext-$PKG release, skip AMO signing," + echo "and bake the OLD signed XPI into :latest — a green build shipping stale code." + echo "Bump the version in BOTH extension/package.json and extension/manifest.json." + exit 1 + fi + echo "OK: extension version $PKG_OLD -> $PKG" + backend-lint-and-test: runs-on: python-ci container: