invisible_playwright/.github/workflows/release.yml
feder-cr 67b5e7cd5e ci: split drive gate — smoke on all 5 legs, full interaction on linux-x86_64
The free hosted runners (windows-latest worst) are content-process unstable
under a heavy headless interaction sequence: clicks/moves cascade into
context-destroyed / selector-timeout / eval-CSP, even across 3 retries, even on
linux-arm64. That's an environment limit, not a binary defect (the binaries
drive 20/20 locally and the stable legs pass).

So: SMOKE (launch + http page + UA + webdriver + DOM read) runs on all 5 legs —
the firefox-8/juggler catcher, robust everywhere. FULL (+ mouse/keyboard/canvas/
navsurface, the firefox-2 class) runs only on linux-x86_64; the interaction code
is platform-identical JS (omni.ja), so one reliable full run covers every
platform, and win interaction stays covered by local pre-release testing.
2026-06-09 15:01:21 +02:00

396 lines
19 KiB
YAML

# ─────────────────────────────────────────────────────────────────────────────
# release.yml — build all 5 patched-Firefox targets at $0 and publish them as
# DRAFT GitHub Release assets, named per the wrapper contract (constants.ARCHIVE_NAME).
# DRAFT on purpose: a human runs the realness gate and only THEN un-drafts + bumps
# BINARY_VERSION. Nothing auto-ships (issue #14 lesson).
#
# PACKAGING (issue #14: dangling symlinks broke 265 downloads — never again):
# Linux → cp -aL (dereference ALL symlinks into real files) + rm dev tools +
# strip + sanitize + tar at ROOT, then validate_release.py as a HARD
# in-pipeline gate (the exact battle-tested script from the source repo).
# Win → mach package; zip the CONTENTS of dist/firefox (clean tree, NOT
# dist/bin) so firefox.exe sits at the zip ROOT.
# macOS → mach package; ad-hoc codesign the .app; PRESERVE its internal relative
# symlinks (a .app legitimately has them — cp -aL would break it); verify
# every symlink is relative+internal; tar the bundle. --version self-gate.
#
# DRIVE GATE (the firefox-8 catcher): after build, every binary is DRIVEN by
# Playwright on its native runner (launch via juggler + real page + JS roundtrip,
# headless, no screenshot → GPU-free, zero proxy). A juggler-less binary renders
# a screenshot fine but is undrivable — only an actual drive catches that. The
# proxy realness gate (fppro/webrtc) stays LOCAL — it needs secrets.
#
# Trigger: push a tag `firefox-N`, or run manually. Hybrid runners, all free.
# ─────────────────────────────────────────────────────────────────────────────
name: release
on:
push:
tags: ['firefox-*']
workflow_dispatch:
inputs:
source_ref:
description: 'invisible_firefox ref to build'
default: 'stealth/150'
release_tag:
description: 'release tag to publish the draft under (e.g. firefox-9)'
required: true
env:
SOURCE_REPO: feder-cr/invisible_firefox
SOURCE_REF: ${{ github.event.inputs.source_ref || 'stealth/150' }}
jobs:
build:
name: build-${{ matrix.leg }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 350
strategy:
fail-fast: false
matrix:
include:
- leg: linux-x86_64
runner: ubuntu-24.04
family: linux
target: ''
rust_target: x86_64-unknown-linux-gnu
win_disables: 'no'
extra_pkgs: ''
asset: firefox-150.0.1-stealth-linux-x86_64.tar.gz
- leg: linux-arm64
runner: ubuntu-24.04-arm
family: linux
target: ''
rust_target: aarch64-unknown-linux-gnu
win_disables: 'no'
extra_pkgs: ''
asset: firefox-150.0.1-stealth-linux-arm64.tar.gz
- leg: win-x86_64
runner: ubuntu-24.04
family: win
target: x86_64-pc-windows-msvc
rust_target: x86_64-pc-windows-msvc
win_disables: 'yes'
extra_pkgs: 'msitools p7zip-full zip'
asset: firefox-150.0.1-stealth-win-x86_64.zip
- leg: macos-arm64
runner: macos-15
family: mac
target: aarch64-apple-darwin
rust_target: aarch64-apple-darwin
win_disables: 'no'
extra_pkgs: ''
asset: firefox-150.0.1-stealth-macos-arm64.tar.gz
- leg: macos-x86_64
runner: macos-15-intel
family: mac
target: x86_64-apple-darwin
rust_target: x86_64-apple-darwin
win_disables: 'no'
extra_pkgs: ''
asset: firefox-150.0.1-stealth-macos-x86_64.tar.gz
steps:
- name: Free disk + 16G swap (Linux runners)
if: matrix.family != 'mac'
run: |
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android \
/usr/local/share/boost "${AGENT_TOOLSDIRECTORY:-/opt/hostedtoolcache}" 2>/dev/null || true
sudo fallocate -l 16G /swapfile && sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile || true
- name: Checkout patched Firefox source
uses: actions/checkout@v4
with:
repository: ${{ env.SOURCE_REPO }}
ref: ${{ env.SOURCE_REF }}
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@v5
with: { python-version: '3.11' }
- name: Install Linux build tools
if: matrix.family != 'mac'
run: |
sudo apt-get update
sudo apt-get install -y util-linux binutils ${{ matrix.extra_pkgs }}
- name: Select Xcode 26.2 + export SDK path (macOS)
if: matrix.family == 'mac'
run: |
sudo xcode-select -s /Applications/Xcode_26.2.app
SDKP="$(xcrun --show-sdk-path)"
echo "SDK_PATH=$SDKP" >> "$GITHUB_ENV"
echo "macOS SDK $(xcrun --sdk macosx --show-sdk-version) at $SDKP"
- name: Add Rust target
run: rustup target add ${{ matrix.rust_target }} || true
- name: Extend the repo .mozconfig (NO mold; +target/SDK as needed)
run: |
test -f .mozconfig || { echo "ERROR: no .mozconfig in source"; exit 1; }
rm -f mozconfig
{
echo ""
echo "# --- release CI levers for ${{ matrix.leg }} (mold intentionally OFF — it segfaults libxul) ---"
echo "ac_add_options --disable-debug-symbols"
} >> .mozconfig
if [ -n "${{ matrix.target }}" ]; then echo "ac_add_options --target=${{ matrix.target }}" >> .mozconfig; fi
if [ "${{ matrix.family }}" = "mac" ]; then echo "ac_add_options --with-macos-sdk=$SDK_PATH" >> .mozconfig; fi
if [ "${{ matrix.win_disables }}" = "yes" ]; then
{ echo "ac_add_options --disable-default-browser-agent";
echo "ac_add_options --disable-maintenance-service";
echo "ac_add_options --disable-update-agent"; } >> .mozconfig
fi
if [ "${{ matrix.family }}" = "mac" ]; then NCPU=$(sysctl -n hw.ncpu); else NCPU=4; fi
{ echo "mk_add_options MOZ_PARALLEL_BUILD=$NCPU";
echo "mk_add_options MOZ_OBJDIR=@TOPSRCDIR@/obj-rel"; } >> .mozconfig
echo "----- final .mozconfig -----"; cat .mozconfig
- name: Build
run: ./mach build
# ── LINUX: dereference symlinks (issue #14) + strip + sanitize + tar@root + GATE
- name: Package + validate (Linux)
if: matrix.family == 'linux'
run: |
set -e
DIST=obj-rel/dist/bin
STAGING=staging
rm -rf "$STAGING"; mkdir -p "$STAGING" out
cp -aL "$DIST/." "$STAGING/" # -L: dereference ALL symlinks into real files
N=$(find "$STAGING" -type l | wc -l)
[ "$N" -eq 0 ] || { echo "ERROR: $N symlinks remain after cp -aL"; exit 1; }
for t in xpcshell certutil pk12util rapl; do rm -f "$STAGING/$t"; done
# JUGGLER GATE: the binary is undrivable by Playwright without it (see 70-known-bugs)
{ [ -e "$STAGING/chrome/juggler.manifest" ] && [ -d "$STAGING/chrome/juggler" ]; } \
|| { echo "ERROR: juggler missing from package (chrome/juggler) — Playwright can't drive it"; exit 1; }
echo "juggler GATE OK (loose chrome/juggler present)"
find "$STAGING" -type f \
\( -name '*.so' -o -name firefox -o -name firefox-bin -o -name plugin-container \
-o -name pingsender -o -name glxtest -o -name vaapitest -o -name updater \) \
-exec strip --strip-debug {} + 2>/dev/null || true
STAGING="$STAGING" python3 scripts/linux_sanitize.py || true # no-op in CI (no /home/feder), defensive
tar --owner=0 --group=0 --numeric-owner --mtime="2026-01-01 00:00:00 UTC" \
-czf "out/${{ matrix.asset }}" -C "$STAGING" . # firefox at ROOT
echo "=== HARD GATE: scripts/validate_release.py (the issue-#14 protector) ==="
python3 scripts/validate_release.py --linux "out/${{ matrix.asset }}" --linux-only
ls -la out/
# ── WINDOWS (cross): zip the CLEAN dist/firefox tree, firefox.exe at root
- name: Package (Windows cross)
if: matrix.family == 'win'
run: |
set -e
# Do NOT swallow a mach failure: `./mach package || echo` lets set -e pass
# and would fall through to a stale tree. A release MUST come from the clean
# dist/firefox; dist/bin is the dev tree (cruft + loose juggler that masked
# the firefox-7/8 packaging bugs), never acceptable for a release.
./mach package
[ -f obj-rel/dist/firefox/firefox.exe ] \
|| { echo "ERROR: mach package did not produce a clean dist/firefox tree"; exit 1; }
WIN_APP=obj-rel/dist/firefox
echo "packaging from: $WIN_APP"
# JUGGLER GATE: omni.ja must carry juggler (else Playwright can't drive it)
[ -f "$WIN_APP/omni.ja" ] || { echo "ERROR: no omni.ja in $WIN_APP"; exit 1; }
python3 -c "import zipfile,sys; sys.exit(0 if any('juggler' in n.lower() for n in zipfile.ZipFile('$WIN_APP/omni.ja').namelist()) else 1)" \
|| { echo "ERROR: juggler missing from $WIN_APP/omni.ja — Playwright can't drive it"; exit 1; }
echo "juggler GATE OK (win)"
mkdir -p out
( cd "$WIN_APP" && zip -qr "$GITHUB_WORKSPACE/out/${{ matrix.asset }}" . ) # firefox.exe at zip ROOT
ls -la out/
# ── macOS: package .app, ad-hoc sign, verify relative-internal symlinks, --version gate, tar
- name: Package + validate (macOS)
if: matrix.family == 'mac'
run: |
set -e
./mach package
APP="$(find obj-rel/dist -maxdepth 2 -name '*.app' -type d | head -1)"
[ -n "$APP" ] || { echo "ERROR: no .app produced"; exit 1; }
echo "built app: $APP"
# JUGGLER GATE: the .app's omni.ja must carry juggler (else Playwright can't drive it)
python3 -c "import zipfile,sys,glob; jas=glob.glob('$APP/Contents/Resources/omni.ja')+glob.glob('$APP/Contents/Resources/browser/omni.ja'); sys.exit(0 if jas and any(any('juggler' in n.lower() for n in zipfile.ZipFile(j).namelist()) for j in jas) else 1)" \
|| { echo "ERROR: juggler missing from .app omni.ja — Playwright can't drive it"; exit 1; }
echo "juggler GATE OK (mac)"
codesign --force --deep --sign - --timestamp=none "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
echo "=== --version GATE ==="
"$APP/Contents/MacOS/firefox" --version
echo "=== critical files present ==="
for need in "Contents/MacOS/firefox" "Contents/Info.plist"; do
[ -e "$APP/$need" ] || { echo "ERROR: missing $need"; exit 1; }
done
echo "=== Info.plist well-formed + required keys (a malformed plist → Finder 'damaged') ==="
plutil -lint "$APP/Contents/Info.plist"
for key in CFBundleExecutable CFBundleIdentifier CFBundleShortVersionString; do
plutil -extract "$key" raw -o - "$APP/Contents/Info.plist" >/dev/null \
|| { echo "ERROR: Info.plist missing $key"; exit 1; }
done
EXEC="$(plutil -extract CFBundleExecutable raw -o - "$APP/Contents/Info.plist")"
[ -e "$APP/Contents/MacOS/$EXEC" ] \
|| { echo "ERROR: CFBundleExecutable '$EXEC' has no matching binary in Contents/MacOS"; exit 1; }
echo "=== verify NO absolute symlinks in the .app (relative-internal ones are fine) ==="
BAD="$(find "$APP" -type l -print0 | xargs -0 -I{} sh -c 't=$(readlink "{}"); case "$t" in /*) echo "{} -> $t";; esac')"
[ -z "$BAD" ] || { echo "ERROR: absolute symlinks in .app (break on user machines):"; echo "$BAD" | head -5; exit 1; }
echo "mac .app OK: critical files present, no absolute symlinks"
STABLE="$(dirname "$APP")/Firefox.app"
[ "$APP" = "$STABLE" ] || mv "$APP" "$STABLE"
mkdir -p out
tar -czf "out/${{ matrix.asset }}" -C "$(dirname "$STABLE")" Firefox.app # preserves internal symlinks
ls -la out/
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: asset-${{ matrix.leg }}
path: out/${{ matrix.asset }}
if-no-files-found: error
retention-days: 7
# DRIVE GATE — the firefox-8 catcher. A raw `firefox --screenshot` proves
# nothing about automation: a juggler-less binary renders fine and ships
# broken (firefox-8 did exactly that). So we DRIVE every binary the way users
# will: Playwright launches it over the juggler pipe, loads a real page, and
# round-trips JS. A binary missing/broken juggler throws TargetClosedError
# here and the release never publishes. Headless, NO screenshot → GPU-free,
# so it can't false-fail on the GPU-less hosted runners. Zero proxy / zero
# secrets → safe in public CI (the proxy realness gate stays local, by design).
# Each leg runs on its NATIVE runner so we test the real artifact, not a cross
# surrogate. Playwright is pinned to a version validated against this build's
# juggler; bump it in lockstep when the juggler is re-synced from upstream.
gate:
name: gate-${{ matrix.leg }}
needs: build
runs-on: ${{ matrix.runner }}
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
include:
# `extra: --full` adds the mouse/keyboard/canvas/navsurface interaction
# checks. Only on linux-x86_64 (historically the most reliable hosted
# runner): the interaction code is platform-identical JS (omni.ja), so
# one reliable full run catches a firefox-2-class regression for all
# platforms. The other legs run SMOKE (launch+http+UA+webdriver) — the
# firefox-8/juggler catcher — which is robust even on the flaky
# windows-latest runner. See scripts/ci_drive_gate.py.
- leg: linux-x86_64
runner: ubuntu-24.04
kind: linux
asset: firefox-150.0.1-stealth-linux-x86_64.tar.gz
extra: '--full'
- leg: linux-arm64
runner: ubuntu-24.04-arm
kind: linux
asset: firefox-150.0.1-stealth-linux-arm64.tar.gz
extra: ''
- leg: win-x86_64
runner: windows-latest
kind: win
asset: firefox-150.0.1-stealth-win-x86_64.zip
extra: ''
- leg: macos-arm64
runner: macos-15
kind: mac
asset: firefox-150.0.1-stealth-macos-arm64.tar.gz
extra: ''
- leg: macos-x86_64
runner: macos-15-intel
kind: mac
asset: firefox-150.0.1-stealth-macos-x86_64.tar.gz
extra: ''
steps:
- name: Checkout wrapper (for scripts/ci_drive_gate.py)
uses: actions/checkout@v4
with: { fetch-depth: 1 }
- name: Download asset
uses: actions/download-artifact@v4
with:
name: asset-${{ matrix.leg }}
path: art
- name: Set up Python
uses: actions/setup-python@v5
with: { python-version: '3.11' }
- name: Install Playwright driver (no bundled browser — we override executable_path)
run: python -m pip install --quiet "playwright==1.55.0"
- name: Linux system deps for headless firefox
if: matrix.kind == 'linux'
run: sudo "$(which python)" -m playwright install-deps firefox
- name: Extract + locate firefox binary
shell: bash
run: |
set -e
mkdir -p ff
A="art/${{ matrix.asset }}"
case "${{ matrix.kind }}" in
win) python -c "import zipfile; zipfile.ZipFile('$A').extractall('ff')"; EXE="ff/firefox.exe";;
linux) tar xzf "$A" -C ff; EXE="ff/firefox";;
mac) tar xzf "$A" -C ff; EXE="ff/Firefox.app/Contents/MacOS/firefox";;
esac
[ -e "$EXE" ] || { echo "ERROR: firefox binary not found at $EXE"; exit 1; }
chmod +x "$EXE" 2>/dev/null || true
echo "FF_EXE=$EXE" >> "$GITHUB_ENV"
echo "located: $EXE"
- name: DRIVE GATE — Playwright launch via juggler + real page (+ interaction on --full)
shell: bash
run: python scripts/ci_drive_gate.py "$FF_EXE" ${{ matrix.extra }}
publish:
name: publish-draft-release
needs: [build, gate]
runs-on: ubuntu-24.04
permissions:
contents: write
steps:
- name: Download all build assets
uses: actions/download-artifact@v4
with: { pattern: asset-*, path: dl, merge-multiple: true }
- name: Assert all 5 target archives present (no silent partial release)
run: |
cd dl
EXPECTED="
firefox-150.0.1-stealth-linux-x86_64.tar.gz
firefox-150.0.1-stealth-linux-arm64.tar.gz
firefox-150.0.1-stealth-win-x86_64.zip
firefox-150.0.1-stealth-macos-arm64.tar.gz
firefox-150.0.1-stealth-macos-x86_64.tar.gz
"
for a in $EXPECTED; do
[ -s "$a" ] || { echo "ERROR: missing/empty release asset: $a (a build leg silently dropped out?)"; exit 1; }
done
echo "all 5 target archives present"
- name: Generate checksums.txt
run: |
cd dl; ls -la
# explicit glob — never include checksums.txt itself (the `*`-includes-itself trap)
sha256sum firefox-150.0.1-stealth-* > checksums.txt
echo "----- checksums.txt -----"; cat checksums.txt
- name: Resolve release tag
id: tag
run: |
TAG="${{ github.event.inputs.release_tag }}"
[ -z "$TAG" ] && TAG="${GITHUB_REF_NAME}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "publishing DRAFT release for tag: $TAG"
- name: Create DRAFT release with all assets
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: invisible_firefox (150.0.1) rev ${{ steps.tag.outputs.tag }}
draft: true
prerelease: false
fail_on_unmatched_files: true
files: |
dl/*.tar.gz
dl/*.zip
dl/checksums.txt
body: |
Patched Firefox 150.0.1 — built on GitHub Actions ($0, no mold).
Targets: linux-x86_64, linux-arm64, win-x86_64, macos-arm64, macos-x86_64.
DRAFT — do not publish until validate_release.py + realness gate pass on all archives.
macOS: ad-hoc signed (not notarized). After download run:
xattr -dr com.apple.quarantine Firefox.app
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}