Track reversed 2026-10-02: Android now follows Firefox release (157.0 onward), not ESR; read "ESR" below as "the Firefox release
version.androidnames". SeeTRACK.md.
Owner: LW-M7-01. This file is the procedure for moving the Android track from
one Firefox ESR version to the next — normally a dot release (153.0esr →
153.1esr), once a year a major one (153.x → the next ESR series).
It is written to be executed by a maintainer who did not write it. If a step here is not enough to act on, that is a defect in this file; fix it in the same pull request as the rebase rather than working it out again next time.
That standard has already been applied to this file once: a skeptical pass found three defects in the first revision — a mechanism asserted as executed that an execution falsifies (6d), a copy-paste path that walked into the trap another section existed to prevent (appendix A), and a "retired" shim that two helper scripts still recreated on every run (appendix C). Everything below carries the command that produced it. If a claim here has no command next to it, do not trust it — run something.
Every number in this file was measured against the tree in this checkout, by running the command shown. Where something was not run, it says so. The §0 cost table, the section 6 pref checks and the section 7 gate outputs were re-measured on 2026-08-17; everything else dates from 2026-08-16.
Since 2026-10-02 both targets follow Firefox release, so the trigger for this
file is every Firefox release: a major every two weeks (the cadence since
2026-09-01, TRACK.md §2) and every dot release in between, which is where
out-of-band security fixes arrive. Each one is a rebase of both targets
onto the same version — version.android and version move together unless
one target is deliberately held back for a release (TRACK.md §6).
The trigger is automatic. .github/workflows/firefox-release-watch.yaml
runs scripts/firefox-release-watch.py every hour (at :23, since LW-M7-47;
once a day before), and by hand from the Actions tab, optionally as a dry
run. It:
- reads
LATEST_FIREFOX_VERSIONfrom https://product-details.mozilla.org/1.0/firefox_versions.json (and logsFIREFOX_ESR/FIREFOX_ESR_NEXTfor desktop information; Redoubt does not track ESR); - compares it numerically with
version.androidandversionon the default branch (157.0<157.0.1<158.0); - if it is newer than either, checks that
releases/<v>/source/firefox-<v>.source.tar.xzis on archive.mozilla.org. A release can be announced before the source tarball is published; until it is there is nothing to rebase onto, so no issue is opened and the next run tries again; - opens one issue per version, titled exactly
Firefox <v> released: rebase Android and desktopwith labelfirefox-release. It looks for that title among every labelled issue, open or closed, first, so it never duplicates or reopens one. Close the issue when the rebase lands (§10).
The issue carries the release notes (desktop and Android), the MFSA index and known-vulnerabilities page, and the step list of this file.
Android-only releases are flagged too. The same run reads version from
https://product-details.mozilla.org/1.0/mobile_versions.json (the Firefox
for Android release; the beta and nightly fields are ignored). When it is
newer than version.android and is not the desktop release (that issue
covers both targets) nor older than it (the desktop rebase goes past it), it
is an Android-only dot such as 153.0.2, and the watcher opens one issue
titled exactly Firefox for Android <v> released: rebase Android, same label,
same open-or-closed dedup. It does not wait for a source tarball, because an
Android-only dot may never get one (153.0.2 did not). The issue says whether
firefox-<v>.source.tar.xz exists. With one, the rebase is the usual one for
Android only (§2 bumps version.android / release.android, not version).
Without one, there is nothing for §3 to verify. Compare the
FIREFOX-ANDROID_<v>_RELEASE tag in Mozilla's Firefox repository with the one
Redoubt builds, then either close the issue with a note (nothing reaches
Redoubt; the next desktop release carries it) or take the fix from the
release branch as a patch (SECURITY.md, "Android-only with no source
tarball"). If mobile_versions.json is unreachable, the desktop half still
runs and the workflow run turns red. No issue is opened about the failure.
§4 is pre-run on the issue. When the watch job has opened the issue, or
finds it still open, and the release has a source tarball, a second job (patchcheck, also GitHub-hosted, never the
self-hosted Android runner) runs scripts/firefox-release-watch.py patchcheck:
- if the issue already carries a patch-check comment for this version and
this commit (a hidden
<!-- redoubt-patchcheck version=… sha=… -->marker), it stops before downloading anything. A new commit on the default branch while the issue is open (patches fixed, say) gets a fresh comment on the next run; a re-run on the same commit posts nothing; - downloads
firefox-<v>.source.tar.xzand its.asc, fetches Mozilla's release key the way §3 / the Makefile does (the pinned assets/mozilla-release-key.asc; formerly keys.openpgp.org, by fingerprint) into a throwaway keyring, and refuses unless the key is exactly14F26682D0916CDD81E37B6D61B7B526D98F0353and gpg'sVALIDSIGnames it as the primary key; - runs
./scripts/check-patchfail.sh --targets=desktop, then--targets=android, in a scratch root whoseversionandversion.androidboth say<v>and whose everything else is a symlink into the checkout. Nothing is bumped or committed. The two runs are sequential, so the ~800 MB tarball and one ~5 GB extraction are all that sit on the runner's disk at once. For a Firefox for Android issue only--targets=androidruns; - posts one comment: per target pass / FAIL / ERROR, patch count, the
failing patches with the reason (hunks failed, target file missing,
reversed), fuzzed and offset hunk counts, the fuzzed patches (for §5a), and
a link to the run. The full reports are kept as the run's
patchfail-<v>-release(or-android) artifact for 30 days.
The comment is a head start, not §4: it tests the patch lists as they are on the default branch, against the pristine tarball. Re-run §4 yourself on the rebase branch.
Run either part locally to see what it would do — read-only, nothing is opened or posted without a token:
python3 scripts/firefox-release-watch.py --dry-run
# patch check, printed not posted; --tarball skips the download (still verified)
python3 scripts/firefox-release-watch.py patchcheck --version 157.0 --dry-run \
--tarball firefox-157.0.source.tar.xz
python3 scripts/tests/test-firefox-release-watch.py # offline, fixtures onlyWhat it does not do:
- It flags only the latest release. If two releases land between two runs (rare on an hourly schedule), only the newer one gets an issue; rebase straight to it.
- It does not rebase anything. It opens an issue and pre-runs §3–§4 into
a comment; §1 onward is still a human (or an agent) working through this
file. Since LW-M7-47 it does three more things, described in
RELEASE-WATCH.md. It assigns every issue to the owner and mentions them. It reads Mozilla's security advisories into the issue (labelsecurity, a daily reminder while the shipped release is behind). For a dot release on the current major whose android patch check passed, it creates the branchauto/firefox-<v>(version files only) and dispatches the unsignedandroid-release.yamlbuild on box B. It never pushes to main, never signs and never publishes. - It does not re-check a closed issue. Once the rebase lands and the issue is closed, the patch check stops for that version.
- It reads the default branch. A rebase sitting on a branch does not silence it; the issue stays open until the bump reaches the default branch and someone closes it.
- It starts on
main. GitHub fires a schedule only from the default branch, so the watcher is inert until the workflow file is merged there. A push that touches only the watcher's files runs its tests on the hosted runner and is excluded fromandroid-test.yaml(paths-ignore), so it does not start the self-hosted Android build.
A rebase is not a clean-apply exercise, and the patch set is not expected to apply
without fuzz. Measured today, on firefox-153.0.4.source.tar.xz:
| run | patches applied | hunks needing fuzz | patches that reject at --fuzz=0 |
|---|---|---|---|
./scripts/check-patchfail.sh |
60 (24 common + 36 desktop) | 31, across 24 patches | 23 |
./scripts/check-patchfail.sh --targets=android --use-desktop-tarball |
28 (24 common + 4 android) | 13 | 12 |
Both default runs exit 0. Both --fuzz=0 runs exit 1. That is the healthy
state, not a problem to fix before starting: fuzz means patch matched the hunk
against slightly drifted context, which is exactly what you want it to do across a
version bump. --fuzz=0 is a report, not a gate.
(The board brief for this task quoted 30 fuzzy hunks and 22 zero-fuzz rejects. The numbers above are what the same commands print today; the patch set moved under it during the M1 wave. Re-measure rather than trusting either figure.)
- ~770 MB tarball download, ~4.7 GB extracted, ~10 GB after a build's object
directory. Two tracks means two of everything — the desktop tree is not
reused (
docs/android/TRACK.md§6). check-patchfailextracts its own throwaway copy each run: ~2 minutes per run including the extract. It is safe to run concurrently with anything else.- A full
./mach buildfor one ABI was 22 minutes and peaked at 31 GB of anonymous memory (docs/android/BUILD.md). Budget accordingly.
AGENTS.md— the five landmines. L1 (WebGL) and L2 (pref precedence) both re-arm on every rebase.TRACK.md— why Android is on ESR and what that costs.PATCH-SCOPE.md— which patches reach Android and why.
Since 2026-10-02 the trigger is automatic: the Firefox release watcher opens an issue for every new Firefox release once its tarball is published (Release-track cadence). That issue is the start of this section. The paragraphs below describe the ESR era and
make check, which is still desktop-only and still must not be run during an Android rebase.
Nothing in this repository watches the ESR channel. make check runs
scripts/update-version.py, which walks the desktop Firefox release series
with curl -I and, when it finds a newer one, writes two files in the repo
root: ./version (open('./version', 'w')) and ./release, through
exec('echo 1 > release') on the next line. It touches neither
version.android nor release.android, and there is no make check-android.
This is deliberate — an ESR dot release should be adopted by a human who has read
the advisory — but it means the trigger is manual.
(Two details worth knowing before you read a diff it produced: it writes
./version without a trailing newline, and it only writes anything at all
when the version it found differs from the one on disk. Do not treat a
no-write run as "checked and up to date on the Android track" — it never looked
at the ESR channel.)
Check these two first, in this order:
- https://whattrainisitnow.com/calendar/ — the "Matching ESR" column gives the ESR point release scheduled against each Firefox major. Under the two-week cadence that is roughly every fortnight.
- https://www.mozilla.org/en-US/security/advisories/ — find the MFSA for the new ESR and read it. You need to know what you are shipping.
Then check the one thing ESR does not give you:
- Any advisory titled "Security Vulnerabilities fixed in Firefox for Android"
with no ESR counterpart. There is no Firefox for Android ESR channel, so
Android-layer fixes are never uplifted to
mozilla-esr153(TRACK.md§3c). Each one has to be triaged by hand and either backported into our tree or published as a known gap. Do not skip this because the rebase itself is clean — a clean rebase says nothing about an Android-only CVE.
⚠️ Do not runmake checkduring an Android rebase. It bumps the desktop track's./versionand will put an unrelated desktop version bump in your rebase commit.
Two files, edited by hand:
printf '153.1esr\n' > version.android # the new ESR version, exactly as
# archive.mozilla.org spells it
printf '1\n' > release.android # reset to 1 on any version changerelease.android is the LibreWolf build number within a Firefox version. Bump it
(and only it) when re-cutting the same Firefox with a patch or settings change; reset
it to 1 whenever version.android changes.
Confirm the Makefile picked both up:
make help TARGETS=android | grep -A5 'Release track'Expected: the android tracks Firefox release line shows the new pair, and
in effect for this invocation shows the same pair. If it shows the desktop pair,
TARGETS did not reach the Makefile.
Nothing else needs editing for a version bump. $(ff_source_tarball),
$(ff_source_url), $(lw_source_dir) and $(version_files) are all derived from
those two files (Makefile:44-65, 121-129, 193).
make fetch TARGETS=androidDry-run it first if you want to see what it will do — make -n is a real dry run
in this Makefile and touches nothing:
make -n fetch TARGETS=androidwhich prints (verified today, with version.android = 153.0esr):
gpg --import assets/mozilla-release-key.asc # pinned in the repo since 2026-10-04 (was: keys.openpgp.org)
gpg --import public_key.asc
rm -f public_key.asc
curl -so firefox-153.0esr.source.tar.xz.asc "https://archive.mozilla.org/pub/firefox/releases/153.0esr/source/firefox-153.0esr.source.tar.xz.asc"
curl -so firefox-153.0esr.source.tar.xz "https://archive.mozilla.org/pub/firefox/releases/153.0esr/source/firefox-153.0esr.source.tar.xz"
gpg --verify firefox-153.0esr.source.tar.xz.asc firefox-153.0esr.source.tar.xz
Three things to know about that recipe:
- The key is pinned by fingerprint (
14F2 6682 D091 6CDD 81E3 7B6D 61B7 B526 D98F 0353, Mozilla's release signing key) in the URL, so the import is not trust-on-first-use. Check the fingerprint in thegpg --importoutput against that string. If it differs, stop. - No ESR-specific
FF_CHANNELis needed. ESR tarballs live under the samereleases/<version>/source/layout as a release build;153.1esris just a version string toarchive.mozilla.org. curlhas no--failhere, so a 404 saves the error body as the tarball. You will find out atgpg --verify, which fails loudly. If gpg reports "no valid OpenPGP data", check the URL exists before suspecting the signature.
firefox-153.0esr.source.tar.xz unpacks to firefox-153.0/, not to
firefox-153.0esr/. Mozilla does not keep the two in step.
LW-M0-14 made the Makefile derive the directory from the tarball rather than
assume it — $(ff_tarball_dir) runs tar tf … | awk at recipe time and takes the
first real member. Before that, make dir TARGETS=android died on
mv firefox-153.0esr librewolf-153.0esr-1 after the download and the extract, which
is the expensive place to find out.
What this means for you:
- Do not hardcode
firefox-$(cat version.android)in anything you write during a rebase. Ask the tarball:tar tf firefox-153.1esr.source.tar.xz | head -3 make cleanremoves both the derived name and the guessed one, so it works whichever way the next ESR is packaged — but only while the tarball is still on disk to ask. Runmake cleanbeforemake distclean, never the other way.- If a future ESR does unpack to a matching name, nothing changes; the derivation yields the same string.
Run both. The Android list is 28 patches, of which 24 are shared with desktop, so a desktop-only check tells you almost nothing about Android and vice versa.
./scripts/check-patchfail.sh --targets=desktop > patchfail-desktop.out 2>&1; echo $?
./scripts/check-patchfail.sh --targets=android > patchfail-android.out 2>&1; echo $?Both must exit 0.
check-patchfail.sh extracts the tarball into its own mktemp -d scratch
directory under the repo root and removes it on every exit path, including ^C. It
never touches a shared tree and two runs cannot collide, so run them in parallel if
you like.
Which tarball each run wants: the desktop run wants
firefox-$(cat version).source.tar.xz; the android run wants
firefox-$(cat version.android).source.tar.xz. The script refuses to download
either — it is run casually and must not start a 766 MB fetch. If the ESR tarball
is not on disk yet:
./scripts/check-patchfail.sh --targets=android --use-desktop-tarballThis is the deliberate escape hatch for checking the Android list before the real
base has landed. It prints a loud !!! WARNING at the top and at the bottom, and
its result is indicative only — you are testing the Android patches against the
wrong Firefox. Never accept a rebase on the strength of a --use-desktop-tarball
run.
success: All patches where applied successfully.and exit 0 — done, go to step 5's zero-fuzz report.Hunk #N succeeded at … with fuzz M— normal. 27 of these on desktop today (common.txt+desktop.txtonfirefox-157.0, across 22 patches, measured at the Firefox 157 merge).error: Some patches failed!with a list of[patches/…]— real rejects. Go to step 5.
check-patchfail.sh uses patch's exit status as the authoritative signal and
captures stderr; the .rej scan is a second signal. That matters because a hunk
whose target file was renamed upstream — the most common shape of rebase
breakage — makes patch print "can't find file to patch" on stderr, write no
.rej, and exit 1. The pre-LW-M0-11 version grepped stdout for rej$ and reported
success (landmine L5). If you ever add a check of your own here, do not reinvent
that bug.
make check-fuzz # -sh -c "./scripts/check-patchfail.sh --fuzz=0" > patchfail-fuzz.outmake check-fuzz exits 0, always — its exit status carries no information.
The recipe (Makefile:462-463) is prefixed with -, so make ignores the
status: the underlying check-patchfail.sh --fuzz=0 does exit 1 (23 desktop
patches and 12 Android patches reject at zero fuzz today), make prints
make: [Makefile:463: check-fuzz] Error 1 (ignored) and returns 0. That is
deliberate — a non-zero exit here is not a failure — but it means you cannot
gate on make check-fuzz. Read patchfail-fuzz.out as a worklist of patches
whose context has drifted far enough to be worth regenerating, not as a red
build.
And note what it covers: make check-fuzz passes no --targets, so
patchfail-fuzz.out is the desktop worklist only. For the Android one — and
for a status you can branch on — call the script rather than the target:
./scripts/check-patchfail.sh --fuzz=0 --targets=android > patchfail-fuzz-android.out 2>&1
echo $? # 1 today, and that is the healthy state(Add --use-desktop-tarball if the ESR tarball is not on disk yet; the result is
then indicative only, exactly as in step 4.)
make fixfuzz # ./scripts/fuzzfail.shFor each patch that rejects at --fuzz=0, this rebuilds it at zero fuzz through
scripts/git-patchtree.sh and writes the result next to it as <patch>.nofuzz. It
classifies three ways:
patch result |
meaning | what fuzzfail does |
|---|---|---|
| exit 0 | clean at zero fuzz | nothing |
.rej written |
ordinary fuzz or a real reject | regenerate <patch>.nofuzz |
exit ≠ 0 and no .rej |
the landmine-L5 shape: the target file is gone | report, never regenerate |
The third row is the important one. There is nothing to regenerate against when the
file has been renamed or deleted upstream, and a .nofuzz written anyway would be
a confident wrong answer. Those patches are listed under patches that need a human, not a fuzz rebuild: and are step 5b's work.
Review each .nofuzz before promoting it:
diff -u patches/foo.patch patches/foo.patch.nofuzz
mv patches/foo.patch.nofuzz patches/foo.patchThen re-run step 4 and confirm the fuzz count dropped.
Dependency: LW-M1-16.
make fixfuzzhad stopped regenerating anything at all —fuzzfail.shwas still callinggit-patchtree.shin the bare form that LW-M1-15 removed, so every fuzzy patch came back as "needs a human" and the run wrote no.nofuzz. It slipped because the verify wasmake -n fixfuzz, which prints commands without running any, and so cannot see a runtime failure.This runbook is written against the intended behaviour. What was verified for this file: the working-tree copies of both scripts carry the fixes — no
git configcall remains anywhere infuzzfail.sh, and it drivesgit-patchtree.shthrough the documented-oform with--targetsand--use-desktop-tarballforwarded. What was not verified: a regeneration was not actually run for this task, because it writes.nofuzzfiles underpatches/, which LW-M7-01 does not own. Before relying onmake fixfuzzin a real rebase, run it once on one patch and confirm the.nofuzzfile appears. Amake -nis not a check.
Never re-introduce the global git-config write.
fuzzfail.shused to setcommit.gpgsignin the maintainer's global git configuration and restore it to a hardcodedtrue, so anyone who had it unset got commit signing switched on for every repository they own, and^Cbetween the two calls left signing globally off.git-patchtree.shnow passes-c commit.gpgsign=falseper invocation instead. A repository script must not write a user's global git settings, ever.
For a patch that genuinely no longer applies, get an editable tree:
./scripts/git-patchtree.sh --edit --targets=android patches/android/neterror-common-esr.patchThis builds a throwaway git repository whose first commit is the pristine files and
whose second is the patch, keeps it (with its .rej files) and prints the path plus
the exact git diff command to produce the new patch. Fix the tree by hand, diff,
write the patch back, re-run step 4.
--targets=android matters: without it you get a tree built from the desktop
tarball, and a patch regenerated against the wrong Firefox will apply cleanly in
check-patchfail and still be wrong.
Do not drop a patch from common.txt to make an error go away. That is a
parity loss and it is the thing this project exists to avoid. If a patch truly
cannot apply on Android, record it in PATCH-SCOPE.md with a reason so it shows up
in the M5 parity matrix.
If you move a patch between lists, re-run scripts/check-patch-order.py: five pairs
share a file in the extracted tree and only apply in one order, and two of the
five are cross-list, so a checker comparing positions within one list cannot see
them at all. The table is in AGENTS.md.
This is the failure mode. A patch reject is loud — check-patchfail prints it,
CI goes red, you cannot miss it. What you can miss is Mozilla changing something
about a pref such that our value stops taking effect, while every patch still
applies and every gate stays green. Nobody finds out until a user does.
There is no single tool for this today. The four checks below are what exists. Run all four.
Ordering. This section is placed here because it is the most important thing in the file, not because it comes next chronologically. 6a and 6c read the patched tree, which does not exist until
make dir TARGETS=androidin step 8; 6b needs the old and new extracted trees side by side; 6d needs a plainfirefox-*/extract. In practice: do step 8, then come back here, then re-run the gates in step 7. Nothing in section 6 modifies anything, so running it twice is free.
The commonest silent revert is an upstream rename or removal. defaultPref on a
name nothing reads is not an error — autoconfig accepts any string — so our
hardening quietly evaporates and the pref page still shows our value.
There is no script for this in scripts/ yet. Save the following as
scripts/pref-orphans.sh (see "Follow-ups" — this file does not own scripts/):
#!/bin/sh
# pref-orphans.sh TREE CFG... -- names we set that the tree never mentions
set -eu
tree=$1; shift
[ $# -gt 0 ] || { echo "usage: $0 TREE CFG..." >&2; exit 2; }
[ -f "$tree/modules/libpref/init/StaticPrefList.yaml" ] || {
echo "error: '$tree' is not a Firefox source tree" >&2; exit 2; }
s=$(mktemp -d) || exit 2
trap 'rm -rf "$s"' EXIT
trap 'rm -rf "$s"; exit 130' INT
trap 'rm -rf "$s"; exit 143' TERM
# 1. every pref name our config sets. Comments are stripped first, but only
# when '//' is not preceded by ':' - otherwise a "https://..." value would
# truncate the line. Newlines are folded so multi-line calls still match.
sed -E 's,([^:])//.*,\1,' "$@" \
| tr '\n' ' ' \
| grep -oE '(defaultPref|lockPref|clearPref|pref)[[:space:]]*\([[:space:]]*"[^"]+"' \
| sed -E 's/.*"([^"]+)"/\1/' | sort -u > "$s/ours"
# 2. names for which the tree declares a default.
{ grep -ohE '^[[:space:]]*pref\([[:space:]]*"[^"]+"' \
"$tree/modules/libpref/init/all.js" \
"$tree/browser/app/profile/firefox.js" \
"$tree/mobile/android/app/geckoview-prefs.js" 2>/dev/null \
| sed -E 's/.*"([^"]+)"/\1/'
grep -oE '^-?[[:space:]]*name:[[:space:]]*[A-Za-z0-9._-]+' \
"$tree/modules/libpref/init/StaticPrefList.yaml" \
| sed -E 's/.*name:[[:space:]]*//'
} | sort -u > "$s/declared"
comm -23 "$s/ours" "$s/declared" > "$s/candidates"
# 3. a pref needs no declared default to be real (PrefWithoutDefault), so the
# survivors are checked against the whole tree as literal strings. -a so a
# match inside a binary test fixture is not silently dropped.
if [ -s "$s/candidates" ]; then
( cd "$tree" && grep -rhoaFf "$s/candidates" . 2>/dev/null ) | sort -u > "$s/used"
else
: > "$s/used"
fi
comm -23 "$s/candidates" "$s/used" > "$s/orphans"
n=$(grep -c . "$s/orphans" || true)
if [ "$n" -eq 0 ]; then
printf 'pref-orphans: %s pref(s) checked, none orphaned.\n' "$(grep -c . "$s/ours")"
exit 0
fi
printf 'pref-orphans: %s of %s pref(s) appear nowhere in %s:\n' \
"$n" "$(grep -c . "$s/ours")" "$tree"
sed 's/^/ /' "$s/orphans"
exit 1Run it against the patched tree, not the stock one — our own librewolf.*
prefs are created by our patches and will otherwise all report as orphans:
chmod +x scripts/pref-orphans.sh
./scripts/pref-orphans.sh librewolf-153.1esr-1 settings/common.cfg settings/android.cfg
./scripts/pref-orphans.sh librewolf-153.1esr-1 settings/common.cfg settings/desktop.cfgTwo design decisions in that script that came out of running it, not out of reading it:
- Stage 3 has no
--includefilter. An earlier version restricted the whole-tree grep to*.cpp *.h *.js *.mjs *.yaml *.rs *.java *.kt …and reportedextensions.gleanPingAddons.daily.intervalas an orphan. It is not: it is registered intoolkit/mozapps/extensions/extensions.manifest, a file type the filter excluded. Removing the filter entirely cost nothing measurable (2.2 s vs 2.1 s over 468 527 files) and removed a whole class of false negative. - Stage 3 exists at all. A pref with no declared default anywhere is still a
real pref —
PrefWithoutDefault<>("network.lna.block_trackers")is the case that previously produced a confident and exactly-backwards "no lock needed". Stage 2 alone would have flagged 33 names today; stage 3 clears 22 of them.
Expected output, and how to triage it. The triage rules below were derived by
running the script against the stock firefox-153.0.4/ tree with common.cfg +
android.cfg — deliberately the harder case, because a stock tree also surfaces our
own librewolf.* prefs and so exercises every category at once. It reported 11 of
189 prefs. That breakdown is the shape to expect every time:
| reported name | verdict |
|---|---|
librewolf.cfg.version, librewolf.console.logging_disabled, librewolf.debugger.force_detach, librewolf.eme.gmp-clearkey.enabled, librewolf.getBrowserInfo.setToFirefoxDefaults, librewolf.services.settings.allowedCollections, librewolf.services.settings.allowedCollectionsFromDump, librewolf.uBO.assetsBootstrapLocation |
ours — created by our own patches. Absent from a stock tree, present in a patched one. Run against the patched tree and these disappear. |
permissions.default.media-key-system-access |
false positive — the name is built at runtime. extensions/permissions/PermissionManager.cpp:830 does GetBranch("permissions.default.", …), so the leaf is never a literal. The permission type string "media-key-system-access" is in the tree, at dom/media/eme/MediaKeySystemAccessPermissionRequest.cpp:42. |
app.update.lastUpdateTime.glean-addons-daily |
false positive — toolkit/components/timermanager/UpdateTimerManager.sys.mjs:7 builds it from the template "app.update.lastUpdateTime.%ID%". |
network.gio.supported-protocols |
genuine orphan. The string supported-protocols appears nowhere in the 153.0.4 tree and netwerk/protocol/gio/ does not exist. settings/common.cfg:200 still sets it, commented "disable gio as it could bypass proxy". Nothing reads it. |
The triage rule for anything this reports: split the name at the last dot and grep the tree for the trailing component on its own. If the suffix turns up as a string literal somewhere, the full name is being constructed at runtime and this is a false positive. If neither the full name nor a distinctive fragment appears anywhere, and the subsystem directory is gone, it is a genuine orphan — find what replaced the pref and set that, or delete our line and note why.
network.gio.supported-protocols above is a live example: it is a real
privacy-motivated setting in settings/common.cfg that has been doing nothing since
GIO was removed upstream. That is what this check is for, and it found one on the
very first run.
The second shape: the pref still exists, we still set it, but Mozilla changed
their default. Usually harmless — our defaultPref still wins — but it is the
signal that the surrounding behaviour changed, and it is the only way to notice
that a value we chose for a reason now means something else (an enum gaining a
member, a timeout changing units, a boolean's polarity being inverted around a
rename).
This is a two-tree diff, so run it while both the old and the new extracted
trees are still on disk. Save as scripts/pref-defaults.sh:
#!/bin/sh
# pref-defaults.sh TREE -- "name<TAB>default" for every pref the tree declares
set -eu
tree=${1:?usage: $0 TREE}
[ -f "$tree/modules/libpref/init/StaticPrefList.yaml" ] || {
echo "error: '$tree' is not a Firefox source tree" >&2; exit 2; }
{
# StaticPrefList.yaml: name and value are separate keys of one mapping.
awk '
/^-[[:space:]]*name:[[:space:]]*/ { n = $3; v = ""; next }
/^[[:space:]]+value:[[:space:]]*/ {
if (n == "") next
sub(/^[[:space:]]*value:[[:space:]]*/, "")
sub(/[[:space:]]+#.*$/, "")
printf "%s\t%s\n", n, $0
n = ""
}
' "$tree/modules/libpref/init/StaticPrefList.yaml"
# the three .js default files: pref("name", value);
cat "$tree/modules/libpref/init/all.js" \
"$tree/browser/app/profile/firefox.js" \
"$tree/mobile/android/app/geckoview-prefs.js" 2>/dev/null \
| sed -nE 's/^[[:space:]]*pref\([[:space:]]*"([^"]+)"[[:space:]]*,[[:space:]]*(.*)\)[[:space:]]*;.*$/\1\t\2/p'
} | sort -uThen:
./scripts/pref-defaults.sh librewolf-153.0esr-1 > /tmp/defaults-old.tsv # the tree you are leaving
./scripts/pref-defaults.sh librewolf-153.1esr-1 > /tmp/defaults-new.tsv # the tree you are moving to
# names whose upstream default row changed at all
comm -3 /tmp/defaults-old.tsv /tmp/defaults-new.tsv | sed -E 's/^\t//' | cut -f1 | sort -u > /tmp/drifted.txt
# every pref name our config sets. Same extraction as stage 1 of pref-orphans.sh;
# 6c wants this file too, so it is written to /tmp/ours.txt rather than piped.
sed -E 's,([^:])//.*,\1,' settings/common.cfg settings/android.cfg | tr '\n' ' ' \
| grep -oE '(defaultPref|lockPref|clearPref|pref)[[:space:]]*\([[:space:]]*"[^"]+"' \
| sed -E 's/.*"([^"]+)"/\1/' | sort -u > /tmp/ours.txt
# the drifted ones that are ours — this is the list to read line by line
comm -12 /tmp/drifted.txt /tmp/ours.txt/tmp/ours.txt is 189 names today (common.cfg + android.cfg). Swap in
settings/desktop.cfg for the desktop answer.
The final comm prints the prefs where we have an opinion and Mozilla changed
theirs. It is normally a handful of lines. Read every one and decide whether our
value still expresses what we meant.
Notes from running this:
- It produces 6 299 rows on 153.0.4. Duplicate rows for one name are normal and
expected —
network.cookie.cookieBehaviorappears as0(StaticPrefList) and5(firefox.js),toolkit.telemetry.unifiedas bothfalseandtruefrom two#ifdefbranches. The check is a diff of two trees produced the same way, so duplicates cancel. - The pipeline was verified end to end by flipping two known values in a synthetic
"old" file and confirming both names came out of the final
comm— not by reading it.
A StaticPrefList.yaml entry with mirror: once snapshots its value into a C++
variable the first time the accessor runs. There is no guarantee that happens after
autoconfig has evaluated librewolf.cfg. A pref that moves into that category
upstream can therefore keep showing our value in about:config while the code that
matters uses Mozilla's.
# /tmp/ours.txt again — repeated so this block runs on its own, without 6b.
sed -E 's,([^:])//.*,\1,' settings/common.cfg settings/android.cfg | tr '\n' ' ' \
| grep -oE '(defaultPref|lockPref|clearPref|pref)[[:space:]]*\([[:space:]]*"[^"]+"' \
| sed -E 's/.*"([^"]+)"/\1/' | sort -u > /tmp/ours.txt
awk '/^- name:/{n=$3} /mirror: once/{print n}' \
librewolf-153.1esr-1/modules/libpref/init/StaticPrefList.yaml | sort -u > /tmp/mirror-once.txt
comm -12 /tmp/ours.txt /tmp/mirror-once.txtExpected output: empty. On 153.0.4 there are 198 mirror: once entries (185
distinct names) and none of them is a pref we set (re-measured 2026-08-17).
Any line here is a real finding and needs a per-pref decision, not a bulk answer.
This is the Android-specific silent revert, and a rebase is exactly when it appears:
Mozilla adds a Pref<> field to GeckoRuntimeSettings.java or
ContentBlocking.java for a pref we already ship. Our value survives startup and is
then overwritten the moment Fenix's settings load, because MOZ_DEFAULT_PREFS
carries no lock as shipped — GeckoLoader emits only bare pref() lines with
no locked attribute (the parser supports the attribute, lib.rs:30; see
AGENTS.md L2).
Name the tree. Do not let the gate look for one.
mkdir -p /tmp/rebase-tree && tar -xf firefox-153.1esr.source.tar.xz -C /tmp/rebase-tree
LW_TREE=/tmp/rebase-tree/firefox-153.1 python3 docs/android/board.py --check-policiesLW_TREE takes any path, inside the repo or outside it, absolute or relative to
the repo root (all three verified). Without it the gate globs firefox-*/mobile
under the repo root, which is fine when exactly one tree is there and an error
when more than one is — see trap 2.
Expected output, both lines:
# reading pref declarations from firefox-153.1
ok: 101 prefs declared by GeckoView; 21 of them shipped by us, all acknowledged as needing a lock
It re-derives the declarations from the tree on every run, so it picks up a new one automatically.
Three things to know about this gate. All three were confirmed by running it, not by reading it — one of them used to be documented backwards for exactly that reason (trap 2).
-
It fails open when no tree is present. With no
firefox-*/directory under the repo root and noLW_TREE, it printswarn: no extracted Firefox tree found — cannot check pref declarationsand exits 0. Andmake dirmoves the extracted tree tolibrewolf-<version>-<release>/, which the glob does not match — so after a normal rebase there is no tree left for it to find and this gate passes vacuously. Insist onok:in the output; exit 0 alone does not distinguish "checked and clean" from "did not check". PassingLW_TREEremoves the trap entirely: a path with nomobile/directory is an error (exit 2), not a warning. -
Two trees on disk is an error, not a coin flip. The gate used to take the first
firefox-*match in sort order and never say which, so on a builder that had both the desktop tree and the ESR tree it silently answered about the wrong one. It now refuses:error: more than one extracted Firefox tree — refusing to guess which one this check should read: /home/you/librewolf/firefox-153.0 /home/you/librewolf/firefox-153.0.4 set LW_TREE=<one of these> and re-runand exits 2. So the fix for the old trap — engineering a name that sorts first — is not only unnecessary, it now causes the failure. Do not do it. Set
LW_TREEand read the# reading pref declarations from …line the gate prints; that line is the answer to "which tree did it check", and it prints on every successful run.The old workaround rested on a false mechanism, which is worth recording so nobody re-derives it:
sorted()here sortspathlib.Pathobjects, and those compare as tuples of path parts, so/is never compared as a character at all. The claim thatfirefox-153.0.4wins because.(0x2E) sorts before/(0x2F) is backwards — executed on real directories,firefox-153.0sorts first, as the error message above (produced by a real run against two trees) shows. A string-sort intuition applied toPathobjects is how that got written down; check the type before reasoning about the order. -
A nonsense parse still prints
ok:. The gate only errors when it finds zeroPref<>declarations. Pointed at a tree containing exactly one, it printedok: 1 prefs declared by GeckoView; 0 of them shipped by us, all acknowledged as needing a lockand exited 0 (verified 2026-08-17 against a hand-built one-declaration tree). So read the count: 101 today. A sudden drop to a handful means it parsed the wrong tree or the GeckoView layout moved, not that the risk went away.
scripts/android-pref-audit.sh (LW-M3-05) does not exist. Neither does
docs/android/expected-prefs.txt. The task that owns them is the runtime pref
audit: launch the app, walk every settings screen without toggling anything, dump
the effective value of every pref we ship, and diff against a checked-in baseline.
That is the only check that would catch a revert happening at runtime rather than in
source — which is precisely the L2 shape 6d can only predict from a Java
declaration.
Until it lands, sections 6a–6d are static analysis of source files. They cannot see:
- a pref whose effective value differs from its declared default because something writes it during startup;
- a Fenix code path that writes a pref without declaring it in one of the two
classes
--check-policiesparses; - any pref set through
GeckoRuntimeSettingsbuilder methods rather than aPref<>field.
Do not record a rebase as pref-verified on the strength of 6a–6d. Record it as "source-level pref checks clean; runtime audit not available (LW-M3-05)".
./scripts/android-smoke.sh (LW-M2-07) does not exist either. When it does, the
rebase gate list gains ./scripts/android-smoke.sh --emulator and
./scripts/android-pref-audit.sh, and AGENTS.md's definition of done already
names both.
The one runtime check you can do by hand today, and should, is landmine L1:
confirm librewolf.webgl.prompt compiled to the value the patch stack intends.
That value depends on which patches are listed in assets/patches/android.txt:
canvas-webgl-permissions.patch (LW-M7-14) listed? |
expected header value | why |
|---|---|---|
| yes (the case since LW-M7-14, and in Beta 2) | true |
the patch removes the #if defined(ANDROID) value: false override that webgl-prompt-default.patch added, because it ships the GeckoView/Fenix permission bridge that answers the prompt |
| no | false |
without the bridge nothing on Android can grant the webgl permission (L1) |
grep -A5 '"librewolf.webgl.prompt",' librewolf-153.1esr-1/obj-*/dist/include/mozilla/StaticPrefList_librewolf.h
grep -n 'canvas-webgl-permissions.patch' assets/patches/android.txtA mismatch either way is a failure. false with LW-M7-14 listed means a WebGL
site never gets asked and the bridge is dead code; true without it means
every WebGL context on Android fails, with no crash and no console error,
while a smoke test of "installs and browses" still passes. Check the
generated header, not the patch: "it compiles to the expected value in the
generated header" beats "the patch sets it". With the bridge present, a
true header is not enough on its own — the runtime proof is the WebGL
prompt flow in scripts/android-graphics-smoke.py and BETA.md's WebGL step.
From 158.0-2, media.redoubt-isolation-media.enabled (locked) decides the media
configuration of isolated content, and is computed from GeckoView's process
model (docs/android/ISOLATION-MEDIA.md sections 0 and 5). Static checks
cannot see it go wrong; check by hand, against the new tree:
-
GeckoRuntime.init()ordering.init()configures theGeckoProcessManagersingleton from its settings and only then callsGeckoThread.init(), which refuses a second start. The gate pref is taken from the FIRSTinit()only. A later default runtime (GeckoRuntime.getDefault(), whose defaultGeckoRuntimeSettingsleave isolation off) or a secondGeckoRuntime.create()would switch new content processes back to plain:tabwhile the gate, the eleven media prefs and the codec allowlist stay true. Fenix 158 never does this (GeckoProvidercreates the one runtime;Core.ktpasses it toGeckoEngineandGeckoViewFetchClient, whosegetDefault()default arguments therefore are never evaluated). Confirm it still holds:grep -rn 'GeckoRuntime.getDefault\|GeckoRuntime.create(' \ mobile/android/fenix/app/src/main \ mobile/android/android-components/components/*/*/src/main # expected: one create() in fenix GeckoProvider.kt; getDefault() only as the # default argument of GeckoEngine / GeckoViewFetchClient, both of which # Core.kt calls with runtime = the GeckoProvider runtime
Also confirm
init()still sets the process manager before it buildsGeckoThread.InitInfo(the patch reads the manager there). -
The drain backport (
isolation-media-drain-backport.patch, upstream bug 1987001) is in Firefox 159: drop the patch, and with it its gate. From then on software ffvpx behaves as upstream on Android 8-11 too, which is a behaviour change for that range to note in the release notes. -
Everything else in
ISOLATION-MEDIA.mdsection 5 (the eleven prefs invariants.yml, theSupports()/CreateDecoder()hooks).
All eight must be green. Run them from the repo root.
esr_tree=/tmp/rebase-tree/firefox-153.1 # edit: the ESR tree 6d extracted
python3 docs/android/board.py --check # board integrity
python3 docs/android/board.py --check-scope # lists vs PATCH-SCOPE.md
python3 docs/android/board.py --check-cfg-split # common+desktop+android == librewolf.cfg
LW_TREE="$esr_tree" \
python3 docs/android/board.py --check-policies # landmine L2 — name the tree, see 6d
python3 docs/android/board.py --diff-mozconfig # no hardening flag silently dropped
python3 scripts/lint-patch-scope.py # no desktop-only file in a common patch
python3 scripts/check-patch-order.py # the five ordering constraints
./scripts/check-patchfail.sh --targets=android # every Android patch still appliesExpected output on a clean tree (measured 2026-08-17, with the 153.0.4 tree extracted in the repo root):
ok: 79 tasks, 17 waves, 0 warning(s)
ok: 64 patch files — 24 common, 36 desktop, 4 android, 0 straddlers parked; PATCH-SCOPE.md agrees
ok: 182 common / 85 desktop / 2 android calls; librewolf.cfg regenerates exactly
# reading pref declarations from firefox-153.0.4
ok: 101 prefs declared by GeckoView; 21 of them shipped by us, all acknowledged as needing a lock
ok: hardening parity holds (0 documented difference(s))
lint-patch-scope: OK - 64 patch file(s), no scope violations
patch order ok: 5/5 declared constraint(s) enforced across 2 target sequence(s); 36 shared-file pair(s) derived and classified.
success: All patches where applied successfully.
The task count in the first line moves whenever the board does — read it as "the gate ran", not as a fixture.
--diff-mozconfig is the one people forget. It compares assets/mozconfig.android
against assets/mozconfig and fails when a hardening flag present on desktop is
missing on Android without a documented reason. A new ESR that adds a
--disable-… option desktop picks up and Android does not is exactly the drift it
exists to catch. It no longer accepts a flag's mere mention in a comment as an
excuse for its absence: an omission has to carry an explicit
MOZCONFIG-OMIT: <name> marker, or the gate fails.
Read the words, not just the exit status. --check-policies still has a
pass-that-did-not-check state (no tree at all, section 6d trap 1) — which is why
the run above names the tree — and check-patchfail exits 0 on a
--use-desktop-tarball run that tested the wrong Firefox. An exit code of 0 is
necessary and not sufficient.
make dir TARGETS=androidThis re-extracts (the $(version_files) prerequisite changed), moves the tree to
librewolf-153.1esr-1/ and runs
python3 scripts/librewolf-patches.py 153.1esr 1 --targets=android, which applies
assets/patches/common.txt then assets/patches/android.txt and exits 1 on the
first patch that does not apply.
Watch its output for the mutations that are not patches and that
check-patchfail cannot see (landmine L4):
- the OpenAI deletions against
toolkit/components/ml/— these assert, so a rename upstream is a loud failure; - the l10n fetch — pinned by commit and sha256 in
assets/l10n-pin.txt. If a rebase needs newer strings, bump the pin using the procedure written in the head of that file, and do not refresh the hash without diffing the trees; - the search-config and icon file copies, and the
version.txtrewrite — still barecp, so an upstream rename fails open: the build succeeds and the change silently does not happen. If you touch a path in that area, make it assert.
Then build per BUILD.md — the container, the lw/l10n directory, the
SELinux :z mount suffix and the -j guidance are all there and are not repeated
here.
podman build -f assets/Dockerfile.android -t librewolf-android-build .
# … then the sequence in BUILD.md §3-§5, ending in ./mach buildA green ./mach build for one ABI is ~22 minutes and ~31 GB of anonymous memory.
Three ABIs is closer to 3× than 1×.
Nothing in this section is implemented yet. Written now so the rebase procedure is complete when it is, and so nobody improvises the parts that must not be improvised.
- The signing key never exists in CI, in a container image, or in any
repository. CI produces an unsigned APK plus published sha256 sums; a
maintainer signs offline on their own machine as a separate step. That boundary
is structural, not a convention (
LW-M6-05), and one convenience commit putting the key in CI undoes the whole custody model (LW-M6-01). - Only signed releases are published. Publishing without a signature is blocked,
not discouraged. The desktop source release already works this way — see
.forgejo/workflows/source-release.yamland commit71177a3. - The
applicationIdand the signing key are never changed. Either one strands every installed user with no upgrade path, and Android has no key recovery outside Play App Signing, which we do not use.
| piece | task | status |
|---|---|---|
release keystore + custody policy (docs/android/SIGNING.md) |
LW-M6-01 | not started — needs a two-maintainer ceremony, not an agent |
| reproducible build + verification | LW-M6-02 | not started |
| LibreWolf F-Droid repository | LW-M6-03 | not started |
| Accrescent, direct APK, Obtainium metadata | LW-M6-04 | not started |
.forgejo/workflows/android-release.yaml |
LW-M6-05 | not started |
| in-app update checking | LW-M6-06 | not started |
Until LW-M6-05 exists there is no Android release workflow at all.
.forgejo/workflows/android-test.yaml is a patch-set check — it fetches the ESR
tarball, runs the board check, the scope lint, the order check, check-patchfail --targets=android and make dir TARGETS=android, and it deliberately builds no
Gecko and references no secrets. It will go green on a rebase pull request without
proving anything about an APK.
- Push the rebase branch;
android-test.yamlruns the patch-set check on it. - Build locally per step 8 and record the result in the pull request:
./mach buildexit status, the L1 header check from 6e, and the outputs of all eight gates. - Do not publish an artifact. There is no signing key and no distribution channel yet.
In the pull request, state:
- old and new
version.android/release.android; - the MFSA the new ESR carries, and any Firefox-for-Android advisory with no ESR counterpart that is still open against our tree (step 1.3);
- the two
check-patchfailruns, with exit status and whether the Android one used--use-desktop-tarball; - fuzz counts before and after, and every patch you regenerated or hand-fixed;
- the output of the four pref checks in section 6, including which of them did not run;
- the eight gate outputs;
./mach buildstatus and thelibrewolf.webgl.promptheader check.
Do not soften the parity wording. Redoubt has a weaker process sandbox than LibreWolf desktop, ESR is a median of 14 days (worst observed 21) behind release on out-of-band Gecko fixes, and there is no Firefox for Android ESR channel at all. Those stay stated.
In execution order, which is not quite section order: the pref checks need the
patched tree, so make dir comes before them.
# 1. decide (manual: whattrainisitnow calendar + MFSA index + Android-only advisories)
# 2. bump
printf '153.1esr\n' > version.android
printf '1\n' > release.android
make help TARGETS=android | grep -A5 'Release track' # confirm the pair is in effect
# 3. fetch + verify
make fetch TARGETS=android
# 4. check patches, both targets
./scripts/check-patchfail.sh --targets=desktop; echo "desktop=$?" # want 0
./scripts/check-patchfail.sh --targets=android; echo "android=$?" # want 0
make check-fuzz # ALWAYS exits 0 (`-` in the recipe); the
# signal is patchfail-fuzz.out, not $?
./scripts/check-patchfail.sh --fuzz=0 --targets=android > patchfail-fuzz-android.out 2>&1
echo "android fuzz0=$?" # 1 today, and that is healthy
# 5. fix rejects
make fixfuzz # review each .nofuzz before promoting it
# ./scripts/git-patchtree.sh --edit --targets=android <patch> # for real rejects
# then re-run step 4 and confirm the fuzz count dropped
# 7. gates (first pass — everything that does not need a tree)
python3 docs/android/board.py --check
python3 docs/android/board.py --check-scope
python3 docs/android/board.py --check-cfg-split
python3 docs/android/board.py --diff-mozconfig
python3 scripts/lint-patch-scope.py
python3 scripts/check-patch-order.py
./scripts/check-patchfail.sh --targets=android
# the eighth gate, --check-policies, needs a tree and is run in the 6d block below
# 8. patch the tree, then build
make dir TARGETS=android # leaves librewolf-153.1esr-1/
# … then BUILD.md for the container build
grep -rn 'webgl.prompt' librewolf-153.1esr-1/obj-*/dist/include/mozilla/StaticPrefList_librewolf.h
# 6. prefs — needs the patched tree above
# (neither pref-*.sh exists in scripts/ yet; copy both out of section 6 first)
./scripts/pref-orphans.sh librewolf-153.1esr-1 settings/common.cfg settings/android.cfg
./scripts/pref-orphans.sh librewolf-153.1esr-1 settings/common.cfg settings/desktop.cfg
# 6b/6c share this list of every pref name we set
sed -E 's,([^:])//.*,\1,' settings/common.cfg settings/android.cfg | tr '\n' ' ' \
| grep -oE '(defaultPref|lockPref|clearPref|pref)[[:space:]]*\([[:space:]]*"[^"]+"' \
| sed -E 's/.*"([^"]+)"/\1/' | sort -u > /tmp/ours.txt
# 6b — upstream defaults that moved (needs BOTH trees still on disk)
old_tree=librewolf-153.0esr-1 # edit: the tree you are leaving
new_tree=librewolf-153.1esr-1 # edit: the tree you are moving to
./scripts/pref-defaults.sh "$old_tree" > /tmp/defaults-old.tsv
./scripts/pref-defaults.sh "$new_tree" > /tmp/defaults-new.tsv
comm -3 /tmp/defaults-old.tsv /tmp/defaults-new.tsv | sed -E 's/^\t//' | cut -f1 | sort -u > /tmp/drifted.txt
comm -12 /tmp/drifted.txt /tmp/ours.txt # read every line
# 6c — prefs that became `mirror: once`. Expected output: empty.
awk '/^- name:/{n=$3} /mirror: once/{print n}' \
librewolf-153.1esr-1/modules/libpref/init/StaticPrefList.yaml | sort -u > /tmp/mirror-once.txt
comm -12 /tmp/ours.txt /tmp/mirror-once.txt
# 6d needs a plain firefox tree, which `make dir` consumed. Re-extract one and
# NAME it — do not drop it in the repo root next to the desktop tree, because
# two firefox-*/ directories now make this gate error out (6d trap 2).
mkdir -p /tmp/rebase-tree && tar -xf firefox-153.1esr.source.tar.xz -C /tmp/rebase-tree
LW_TREE=/tmp/rebase-tree/firefox-153.1 python3 docs/android/board.py --check-policies
# want: "# reading pref declarations from firefox-153.1" then "ok: … prefs declared"
# 9. sign + publish — not implemented (LW-M6-01..06)
# 10. record everything in the pull requestKeep this honest; it is the difference between a rebase that was checked and one that merely went green.
| gate | blind to |
|---|---|
check-patchfail.sh |
anything scripts/librewolf-patches.py does outside the patch lists — the cps and the version.txt rewrite (L4). With --use-desktop-tarball, blind to the actual ESR tree. |
check-fuzz (--fuzz=0) |
nothing about correctness — it reports drift. And make check-fuzz is blind to its own result: the recipe is --prefixed, so the target exits 0 whatever the script did. |
lint-patch-scope.py |
prefs, build flags, runtime behaviour. It answers one question: is a desktop-only file inside a common patch. |
check-patch-order.py |
the direction of a new constraint. It flags new shared-file pairs for review; the five declared constraints are a table, not a derivation. |
board.py --check-scope |
whether a patch still applies. It compares lists to a document. |
board.py --check-cfg-split |
whether the prefs still exist upstream. It proves the three .cfg files reassemble into librewolf.cfg, nothing more. |
board.py --check-policies |
anything, if no firefox-*/ tree is present and no LW_TREE is set — it warns and exits 0. It no longer guesses between several trees (that is an error now), but it also cannot tell you the tree you named is the right one: read the # reading pref declarations from … line it prints. |
board.py --diff-mozconfig |
flags that exist on both sides but changed meaning upstream. |
| all of them | a pref whose effective runtime value is wrong. That is LW-M3-05, and it does not exist. |
assets/patches.txt was a generated common + desktop concatenation, kept after
LW-M0-02 split the monolith so out-of-tree builders had one file to read. It has
been deleted. During a rebase there is nothing to regenerate and nothing to keep
in sync: the lists the build reads are
assets/patches/common.txt 24 entries — applied to every target
assets/patches/desktop.txt 36 entries — desktop builds only
assets/patches/android.txt 4 entries — Android builds only
scripts/librewolf-patches.py reads assets/patches/*.txt directly, and LW-M1-11
taught check-patchfail.sh and fuzzfail.sh the target lists, so no build,
workflow, container image or test consumed the shim by the time it was removed. The
Makefile says so explicitly in the comment above patch_lists := ("a generated
shim … that nothing in this recipe reads any more, so it is deliberately not a
prerequisite"), and the source release tars $(lw_source_dir) — the patched Firefox
tree — not this repository, so the shim never reached a downstream builder through
make all either.
Deleting the file was only half the retirement. Three of the four scripts that touched it needed changing, and until they were, the deletion did not hold: two of them wrote the file back unconditionally, on every run.
(Function names, not line numbers — this set of scripts has been edited by a dozen tasks and any number quoted here would already be wrong.)
| file | what it did | state now |
|---|---|---|
scripts/enable-patch.sh — regenerate_shim(), called at the end of every run |
wrote the file after every edit, with no existence guard | removed (function, SHIM variable, call site and the "Regenerated the …" line). |
scripts/disable-patch.sh — the same function, same call site |
same | removed, same four pieces. |
scripts/librewolf-patches.py — check_compat_shim(), called from librewolf_patches() |
read it to warn on membership drift; with the file gone it printed warning: can't read … No such file or directory on every invocation |
removed, function and call. |
scripts/check-patchfail.sh — the shim_warn block and the warning it prints at the end |
reads it for a set-membership drift warning | inert, not removed. The read is guarded by [ -f … ], so with the file gone the warning cannot fire. It is dead code in a file no task in this thread owns; see follow-up 6. |
How that was proved, in both directions (2026-08-17, all in a scratch copy of the repo so nothing here changed):
- Before:
./scripts/disable-patch.sh --list desktop patches/msix.patchin a scratch tree with noassets/patches.txtcreated a 59-lineassets/patches.txt. The resurrection was real, not theoretical. - After: enabling a probe patch and then disabling it again — once for each of
common,desktopandandroid— left all three list files byte-identical to their starting state (sha256sum -c, three OKs per round trip) and created noassets/patches.txt. The ordering-insertion path was exercised too (removingautoconfig-setEnvand re-adding it puts it back beforeprofile-directory, as the constraint requires) and it also leaves no shim. - Patcher:
python3 scripts/librewolf-patches.py -n 153.0esr 1 --targets=androidand the desktop equivalent now both exit 0 with nowarning:line of any kind. - check-patchfail: a full
./scripts/check-patchfail.sh --targets=desktoprun exits 0 and prints nopatches.txtline at all.
If you ever find assets/patches.txt back on disk, something regenerated it.
Delete it and find what wrote it — there is no longer any code that is supposed
to.
Executed per §1-§7 on branch android/esr-153.4. Stage A only: no
make dir, no Gecko build — §8, the §6e runtime audit and the L1 header
check belong to stage B and were not run here. Stage B and the follow-ups
are recorded inline below, marked as such. Settings submodule at 0cae8fb.
ESR advisories now carried (CVE counts by impact, parsed from each MFSA page):
| MFSA | release | CVEs | high / moderate / low |
|---|---|---|---|
| 2026-77 | Firefox ESR 153.1 | 52 | 18 / 24 / 10 |
| 2026-85 | Firefox ESR 153.2 | 24 | 12 / 5 / 7 |
| 2026-93 | Firefox ESR 153.3 | 63 | 25 / 21 / 17 |
| 2026-100 | Firefox ESR 153.4 | 62 | 34 / 20 / 8 |
None of the four mentions Android. Step 1.3 — Firefox-for-Android fixes with no ESR counterpart (in MFSA 2026-73 or the Firefox 154-157 advisories 2026-74/82/90/97, absent from all four ESR advisories). All are open against our tree; none was backported in this rebase:
| CVE | advisory | impact | title | bug |
|---|---|---|---|---|
| CVE-2026-18809 | 2026-73 (Android 153.0.3) | high | Information disclosure in Firefox for Android and Focus | 2055683 |
| CVE-2026-74951 | 2026-74 (154) | moderate | Clickjacking issue in Firefox for Android | 1978587 |
| CVE-2026-74975 | 2026-74 (154) | low | Spoofing issue in the Downloads component | 1842361 |
| CVE-2026-74980 | 2026-74 (154) | low | Clickjacking issue in the Downloads component | 2049034 |
| CVE-2026-84117 | 2026-82 (155) | high | Privilege escalation in Firefox for Android | 2053320 |
| CVE-2026-84127 | 2026-82 (155) | moderate | Information disclosure in the WebExtensions component | 1699444 |
| CVE-2026-84135 | 2026-82 (155) | low | Other issue in Firefox Focus for Android (not shipped by us) | 2046661 |
| CVE-2026-92033 | 2026-90 (156) | high | Privilege escalation in Firefox for Android | 2047339 |
| CVE-2026-100823 | 2026-97 (157) | low | Spoofing issue in the Downloads component | 2054384 |
Follow-up 2026-10-04: closed by the move to Firefox release 157.0 (Beta 4
on), CVE-2026-84135 aside (Focus-only, never shipped). Each fix was checked
present in the 157.0 source: PARITY.md §7.
Consistent with that: between the two tarballs only two files under mobile/
differ at all (GeckoAppShell.java, HDR brightness getters;
fenix/app/nimbus.fml.yaml, a Nimbus gecko-pref for
network.lna.block_insecure_contexts, inert under no-nimbus).
version.android 153.0esr → 153.4.0esr (153.4esr is a 404 on
archive.mozilla.org; 153.4.0esr is 200), release.android stays 1.
make help TARGETS=android prints (153.4.0esr-1) and in effect for this invocation : 153.4.0esr-1. Tarball signature: Good signature from "Mozilla Software Releases", primary key 14F2 6682 D091 6CDD 81E3 7B6D 61B7 B526 D98F 0353.
| run | exit | hunks with fuzz | patches rejecting at --fuzz=0 |
|---|---|---|---|
| before, 153.0esr | 0 | 14 (12 patches) | 14 |
| after bump, unfixed, 153.4.0esr | 1 — 3 rejects | 15 | 18 |
| after fixes, 153.4.0esr | 0 | 15 (13 patches) | 15 |
after the session-cleanup context refresh (2026-10-02 follow-up) |
0 | 14 (12 patches) | 14 |
The three rejects and what was done:
android/ubo-readiness— all 3ext-webRequest.jshunks. Upstream removedremoteTab/registerTraceableChannelfromregisterEventand the registrar. Ported by hand:isLiveListeneris now the 6th parameter,convert(_fire)still callsmarkLive(), the registrar takes{ fire, context }and passes!!context. Checked withscripts/tests/test-ubo-readiness.jsagainst the patched tree: 18/18 once itsregisterEvent(..., info, null, live)call drops thenull(the unmodified test stops silently after 2 of 18 with exit 0 — see follow-ups).android/extension-update-controls—Preferences.cpphunks 2 and 6. Upstream bug 2053962 added a backup-onlyPWRunnable(aFile, aData, holder),DispatchWriteComplete()and a standalone-dispatch branch inWritePrefFile, fixing the same dropped-promise/wrong-file coalescing bug our patch fixes for every write. OurPWRunnable(each request owns its snapshot, settles its own promise) replaces upstream's; the diff of change lines against the old patch is exactly upstream's new backup code removed.android/fix-canvas-extraction-permission-esr— upstream carries the fix in 153.4.0esr. Deleted and dropped fromandroid.txt(PATCH-SCOPE.md).
Fuzz deliberately not regenerated: the 7 fuzzy common.txt patches (shared
with desktop 157), the four -esr byte copies (pre-existing on 153.0esr; they
are dropped at ESR >= 157), gradle-no-config-cache (pre-existing), and the
one new fuzzy hunk, session-cleanup CookiePersistentStorage.cpp include
block (fuzz 2, benign context: upstream added #include "mozilla/AppShutdown.h"). Regenerating either Android patch changes its
sha256, and scripts/tests/test-session-cleanup.py /
test-extension-update-controls.py and evidence receipts pin those bytes.
Superseded by the follow-up: once those receipts were re-captured for
153.4.0esr, the session-cleanup include-block hunk was refreshed. Only
context and hunk headers changed, no +/- line. It now applies at
--fuzz=0 with no offsets, and its 15 output files are byte-identical to the
fuzzy result that stage B built. See
evidence/lw-m7-01/esr-153.4.0/receipts/README.md.
android/firefox-suggest-data's app/build.gradle section had a bare @@
hunk header that GNU patch skips silently (exit 0, no .rej), so
implementation ComponentsDependencies.mozilla_appservices_suggest never
landed on any base. It now carries @@ -545,6 +545,13 @@ dependencies {,
generated against the tree patched by every earlier entry; it lands at fuzz 0
on both 153.0esr and 153.4.0esr, and the final app/build.gradle is sha256
33826457… in shipping order and with suggest-data before each of the 7 other
build.gradle editors. scripts/lint-patch-scope.py now rejects any hunk header
without line numbers in every listed patch.
Trees: common.txt + android.txt applied to fresh 153.0esr and 153.4.0esr
extracts (patches only — not a make dir tree, so no cp/l10n mutations).
- 6a ran.
pref-orphans.shwithcommon.cfg android.cfg: 12 of 202 on 153.4.0esr, and the same 12 on 153.0esr — nothing newly orphaned (app.update.lastUpdateTime.glean-addons-daily,browser.ml.onnxNativeAvailabilityReported,browser.netError.searchCTA.enabled,extensions.formautofill.passports.enabled,librewolf.cfg.version,network.gio.supported-protocols,permissions.default.media-key-system-access,privacy.containers.switchDuringNavigation.enabled,security.tls.enable_mldsa,security.tls.enable_mlkem1024,signon.storage.rust.enabled,toolkit.telemetry.site_categories). The desktop.cfg run belongs to the 157 tree and is not part of this rebase. - 6b ran. 6 291 → 6 299 rows; 11 names drifted; none is ours (202
names). Worth an owner look, not ours to change here:
network.lna.block_insecure_contextstrue→false(upstream: disabled until Chrome ships it); we set four othernetwork.lna.*prefs. - 6c ran. 185 distinct
mirror: oncenames on both trees, unchanged; intersection with ours: empty. - 6d ran.
LW_TREE=<plain 153.4.0 extract> board.py --check-policies:# reading pref declarations from firefox-153.4.0/ok: 137 prefs declared by GeckoView; 26 of them shipped by us, every unlocked one classified in must-not-lock.txt— identical to the 153.0 tree. NoPref<>line changed undermobile/. - 6e not run in stage A.
scripts/android-pref-audit.shanddocs/android/expected-prefs.txtnow exist (§6e above predates them), but the audit needs a running 153.4.0esr APK — stage B. L1:librewolf.webgl.promptin the patched yaml is the deliberate LW-M7-14true, byte-identical to 153.0esr. Stage B (added 2026-10-02): the generatedStaticPrefList_librewolf.hreadsRelaxedAtomicBool, trueforlibrewolf.webgl.prompt(andtruefor.hide) in all three objdirs (obj-arm64-v8a,obj-armeabi-v7a,obj-x86_64), which is the expected value withcanvas-webgl-permissions.patchlisted — see the table in §6e. The runtime audit ran on the x86_64 APK; results inevidence/lw-m7-01/esr-153.4.0/README.md.
No revert of a pref we set was found, so nothing was changed in settings/.
ok: 122 tasks, 32 waves, 0 warning(s)
ok: 119 listed patch files — 15 common, 53 desktop, 51 android, 0 straddlers parked; PATCH-SCOPE.md agrees
ok: 181 common / 200 desktop / 20 android calls; librewolf.cfg regenerates exactly
# reading pref declarations from firefox-153.4.0
ok: 137 prefs declared by GeckoView; 26 of them shipped by us, every unlocked one classified in must-not-lock.txt
ok: hardening parity holds (0 documented difference(s))
lint-patch-scope: OK - 119 patch file(s), no scope violations, no hunk header without line numbers
patch order ok: 29/29 declared constraint(s) enforced across 2 target sequence(s); 154 shared-file pair(s) derived and classified.
success: All patches where applied successfully.
- The nine Android-only CVEs above: triage/backport or publish as known gaps.
Done: thescripts/tests/test-ubo-readiness.jscallsregisterEventwith the 153.0 positionalremoteTabslot; drop thenullargument. Its harness also exits 0 when a test never settles — it should count to 18.nulland the staleconvert()remoteTab argument are gone, and the harness exits 1 unless all 18 tests finished (18/18 on the patched 153.4.0esr tree).Done for all three pinned tests, includingtest-extension-update-controls.pyandtest-session-cleanup.pyassert the 153.0esr sha256 ofextension-update-controls.patch; the LW-M7-35/37 evidence receipts need re-capturing on 153.4.0esr.test-global-privacy-controls.py(LW-M7-36). The receipts were re-captured from the signed tarball plus the Android stack, with an exact--fuzz=0replay. The whole-stack result matches the stage-B tree on every path (27/27, 21/21, 15/15). Each test picksevidence/lw-m7-01/esr-<version.android>/receipts/when it exists. Still 153.0esr-only: the task-locallw-m7-35/36/37 check-ordering.pyandlw-m7-36/check-source.py, outsidescripts/tests.Stage B:Done. The header ismake dir TARGETS=android,./mach build, the L1 header grep,android-pref-audit.shon the built APK.true, as §6e expects with LW-M7-14 listed. The audit result is inevidence/lw-m7-01/esr-153.4.0/README.md.- Unit tests on the 153.4.0esr build tree:
fenix:testDebugUnitTestran 615 classes / 5,562 tests, 22 failing = 19 environmental + 3 known-real + 0 unexpected (board.py --check-fenix-testsexit 0). It warns thatSearchSuggestionsProvidersBuilderTestnow passes (70/70) andAutofillSettingsMiddlewareTestfails 2, not 3. Both entries are now trimmed: the first is dropped and the second's ceiling is 2. Re-graded against the archived XML, the result is exit 0 with no warnings (19 + 3 + 0). Running the Android Components modules our patches touch found three test files that had never compiled or passed, and they are now fixed.PinnedSuggestIngestionTest(firefox-suggest-data) used mockk, which android-components excludes, so it now uses Mockito.AddonUpdaterWorkerTest(extension-update-controls) used Mockito'seq, which returns null for a Kotlin non-nullString, so it now uses the support-testeq.GeckoGlobalPrivacyControllerTest(global-privacy-controls) had a redundant!!that is a warning under-Werror. Two more classes asserted upstream behaviour, and the owning patches now assert ours. InFxSuggestSuggestionProviderTest(firefox-suggest-policy), two cases expected a query with an empty provider list; they now verify that the store is never queried. InAddonsManagerAdapterTest"bind blocklisted add-on" (l10n-strings), the test now reads the lw-brand-rewritten resource and asserts that it does not name Mozilla.:components:feature-fxsuggestpasses 73/73 and:components:feature-addonspasses 218/218.
The changes listed in the items above, plus two stale scripts/tests:
scripts/librewolf-patches.py: the LW-M4-07 brand-image existence check ran under--no-execute, which never enters the tree, sotest-ubo-extension.py's dry-run case exited 1. It is now skipped there, like every other existence check.test-android-signing.py: the default bundle islibrewolf-android-apk-<version.android>-<release.android>/apk, not a hardcoded 153.0esr path.
Re-run on the 153.4.0esr tree after those commits:
scripts/tests: 15 .py + 8 .js, all exit 0. Two take inputs:
test-android-signing.py --apk <stage-B x86_64 APK> --apksigner <handoff apksigner.jar>
29/29.
test-android-version-code.py --classes/--gradle-home, in the build container
5/5. With no arguments it is a usage error (exit 2), as before.
board.py --check ok: 122 tasks, 32 waves, 0 warning(s)
board.py --check-scope ok: 120 listed patch files — 15 common, 53 desktop, 52 android
board.py --check-cfg-split ok: 181 common / 200 desktop / 20 android calls
board.py --diff-mozconfig --strict ok: hardening parity holds (0 documented difference(s))
board.py --check-fenix-tests --results <archived 153.4 XML> ok, 19 + 3 + 0, no warnings
lint-patch-scope OK - 120 patch file(s)
check-patch-order 30/30 constraints, 162 shared-file pairs
check-patchfail --targets=android exit 0; 14 fuzzy hunks in 12 patches
check-patchfail --targets=android --fuzz=0 14 patches reject (15 before the refresh); session-cleanup is clean
LW-M7-01 owned docs/android/REBASE.md and assets/patches.txt; the correction
pass that followed it also owned scripts/librewolf-patches.py,
scripts/enable-patch.sh and scripts/disable-patch.sh. Everything below belongs
to a file neither owned, and is recorded here so it is not lost:
scripts/pref-orphans.shandscripts/pref-defaults.share embedded in section 6 rather than added toscripts/. Both were written and run for this task;pref-orphans.shwas tested on its clean path (exit 0), its finding path (exit 1 with the name printed) and both error paths (not-a-tree and no-cfg, exit 2). They should become real scripts and be wired into.forgejo/workflows/android-test.yaml.network.gio.supported-protocolsinsettings/common.cfg:200is dead —netwerk/protocol/gio/does not exist in Firefox 153 and the stringsupported-protocolsappears nowhere in the tree. It belongs to thesettingssubmodule, which needs its own pull request.board.py --check-policiesstill fails open when there is no tree at all. The tree-guessing half of this is fixed — ambiguity is an error,LW_TREEoverrides, and the chosen tree is printed (6d) — but with no tree and noLW_TREEit still warns and exits 0. Remaining fix inboard.py: exit non-zero, or make the tree mandatory..forgejo/workflows/android-test.yamlruns four of the eight gates —board.py --check,lint-patch-scope.py(still wrapped as advisory, though it passes today),check-patch-order.pyandcheck-patchfail --targets=android. It does not run--check-scope,--check-cfg-split,--check-policiesor--diff-mozconfig, and--check-policieswould fail open there anyway becausemake dirleaves nofirefox-*tree behind — passLW_TREEwhen wiring it in.docs/android/PATCH-SCOPE.mdstill documents the shim as live, in the present tense, at seven places::8,:23-24("survives only as a generatedcommon + desktopshim for downstream builders and is retired by …"),:26and the regeneration command at:31,:35("check-patchfail.shapplies the shim top to bottom"),:62and:261. Two of those describe code that no longer exists at all —check_compat_shim()and the shim walk incheck-patchfail.sh, which has read the real lists since LW-M1-11. Separately,docs/android/README.md's "Start here" block lists five gates, not eight, and does not link this file, and.forgejo/workflows/android-test.yaml:106still says check-patchfail "walks the common+desktop shim".- Three files still mention the retired shim (none of them recreates it, and
none is owned here):
assets/patches/common.txt:8-12— the only one that is actively wrong. Present tense, and a standing instruction: the shim "is GENERATED from this file plus desktop.txt … and must be regenerated whenever the membership of either changes - it is what scripts/check-patchfail.sh, scripts/fuzzfail.sh and the enable/disable helpers still read. The patcher compares it as a set and warns if the two disagree. LW-M7-01 retires it." Every clause is false now: nothing regenerates it, nothing reads it, and the patcher check is gone. Delete the paragraph.assets/patches/desktop.txt:6and:30— both historical and both still true as written ("the order these entries had in the pre-split assets/patches.txt", "LW-M1-12 … regenerated the assets/patches.txt shim"). They describe the past, so they are not wrong; they are just the last mentions of a file a reader can no longer find. Worth a "(since removed)".scripts/check-patchfail.sh— theshim_warnblock, its[ -f … ]guard and the multi-line warning it can no longer print, plus the "Which lists are walked" comment that still explains the shim. Dead but harmless.Makefilealso still namesassets/patches.txtin the comment abovepatch_lists :=, correctly, to explain why it is not a prerequisite; that one can stay.
- The board does not describe the current state.
LW-M7-01indocs/android/tasks.yamlstill declaresowns: [docs/android/REBASE.md, assets/patches.txt]— the second path no longer exists — andtasks.yaml:935still namescheck_compat_shim(), a function that is gone. There is also no board entry for the correction pass that produced this revision: it was briefed asLW-M7-07, andboard.py --show LW-M7-07answerserror: no such task. M7 stops atLW-M7-06.