From eec373a719e4a8a34cdba0e03c75ed08bb2254c0 Mon Sep 17 00:00:00 2001 From: feder-cr <85809106+feder-cr@users.noreply.github.com> Date: Tue, 9 Jun 2026 10:45:27 +0200 Subject: [PATCH] ci: $0 5-target GitHub Actions release pipeline + wrapper macOS/arm64 support release.yml builds linux-x64/arm64 + win-x64 (cross) on free Linux runners and macos-arm64/x64 on native Mac runners; packages per the wrapper contract (juggler-gated so binaries are Playwright-drivable, issue-#14 symlink-safe via cp -aL), validate_release.py gate, ad-hoc macOS codesign, DRAFT publish. constants.py: arm64 + darwin ARCHIVE_NAME + BINARY_ENTRY_REL (Firefox.app). download.py: macOS post-extract xattr quarantine strip. BINARY_VERSION unchanged (firefox-8); the juggler-fixed firefox-9 is a separate release cut + pin bump. --- .github/workflows/release.yml | 307 ++++++++++++++++++++++++++ src/invisible_playwright/constants.py | 11 +- src/invisible_playwright/download.py | 29 +++ tests/test_constants.py | 32 +-- tests/test_download.py | 2 +- 5 files changed, 365 insertions(+), 16 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b987ad6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,307 @@ +# ───────────────────────────────────────────────────────────────────────────── +# 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. Runtime-gated on a real +# windows-latest runner (headless screenshot). +# 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. +# +# 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 + ./mach package || echo "mach package rc=$? (continuing to locate the app tree)" + # Prefer the clean packaged tree; fall back to dist/bin if cross didn't produce dist/firefox + if [ -f obj-rel/dist/firefox/firefox.exe ]; then WIN_APP=obj-rel/dist/firefox + elif [ -f obj-rel/dist/bin/firefox.exe ]; then WIN_APP=obj-rel/dist/bin + else echo "ERROR: firefox.exe not found in dist/firefox nor dist/bin"; exit 1; fi + echo "packaging from: $WIN_APP" + # JUGGLER GATE: omni.ja must carry juggler (dist/firefox) or loose chrome/ (dist/bin fallback) + if [ -f "$WIN_APP/omni.ja" ]; then + 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; } + elif [ ! -d "$WIN_APP/chrome/juggler" ]; then + echo "ERROR: juggler missing from $WIN_APP (no omni.ja juggler, no loose chrome/juggler)"; exit 1 + fi + 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 "=== 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 + + # Windows binary is cross-built on Linux → gate it on a real Windows runner. + gate-windows: + name: gate-windows + needs: build + runs-on: windows-latest + timeout-minutes: 20 + steps: + - name: Download win asset + uses: actions/download-artifact@v4 + with: { name: asset-win-x86_64, path: art } + - name: Extract + structure + headless render gate + shell: pwsh + run: | + $zip = Get-ChildItem art -Filter *.zip | Select-Object -First 1 + Expand-Archive $zip.FullName -DestinationPath ff -Force + foreach ($f in 'firefox.exe','application.ini','dependentlibs.list') { + if (-not (Test-Path (Join-Path ff $f))) { throw "missing critical file: $f" } + } + $exe = Join-Path (Resolve-Path ff) 'firefox.exe' + Remove-Item shot.png -ErrorAction SilentlyContinue + $p = Start-Process -FilePath $exe -ArgumentList '--headless','--no-remote','--profile','prof','--screenshot',"$PWD\shot.png",'https://example.com' -PassThru -NoNewWindow + if (-not $p.WaitForExit(90000)) { $p.Kill(); throw 'win gate TIMEOUT' } + Start-Sleep 1 + if (-not (Test-Path shot.png) -or (Get-Item shot.png).Length -lt 3000) { throw 'win gate: no/empty screenshot' } + Write-Output "win gate OK: firefox.exe runs + renders ($((Get-Item shot.png).Length) bytes)" + - uses: actions/upload-artifact@v4 + with: { name: gate-win-screenshot, path: shot.png, if-no-files-found: warn, retention-days: 7 } + + publish: + name: publish-draft-release + needs: [build, gate-windows] + 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: 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 + 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 }} diff --git a/src/invisible_playwright/constants.py b/src/invisible_playwright/constants.py index 295ebf5..95a150d 100644 --- a/src/invisible_playwright/constants.py +++ b/src/invisible_playwright/constants.py @@ -19,13 +19,15 @@ BINARY_BASENAME: str = f"firefox-{FIREFOX_UPSTREAM_VERSION}-stealth" def ARCHIVE_NAME(platform_key: str, machine: str) -> str: """Return the platform-specific archive filename. - platform_key: sys.platform ("win32", "linux") - machine: platform.machine() ("AMD64", "x86_64", ...) + platform_key: sys.platform ("win32", "linux", "darwin") + machine: platform.machine() ("AMD64", "x86_64", "arm64", "aarch64", ...) """ pk = platform_key.lower() m = machine.lower() if m in {"amd64", "x86_64"}: arch = "x86_64" + elif m in {"arm64", "aarch64"}: + arch = "arm64" else: raise NotImplementedError(f"unsupported arch: {machine}") @@ -33,13 +35,18 @@ def ARCHIVE_NAME(platform_key: str, machine: str) -> str: return f"{BINARY_BASENAME}-win-{arch}.zip" if pk == "linux": return f"{BINARY_BASENAME}-linux-{arch}.tar.gz" + if pk == "darwin": + return f"{BINARY_BASENAME}-macos-{arch}.tar.gz" raise NotImplementedError(f"unsupported platform: {platform_key}") # Binary entry point relative path inside the extracted archive root. +# macOS ships the .app bundle (renamed to a stable "Firefox.app" by release.yml); +# the wrapper execs the inner binary directly, which sidesteps Gatekeeper. BINARY_ENTRY_REL = { "win32": "firefox.exe", "linux": "firefox", + "darwin": "Firefox.app/Contents/MacOS/firefox", } # GitHub release URL template. The "TODO" owner is resolved at publication time. diff --git a/src/invisible_playwright/download.py b/src/invisible_playwright/download.py index 7417e39..13dd62c 100644 --- a/src/invisible_playwright/download.py +++ b/src/invisible_playwright/download.py @@ -6,6 +6,7 @@ import os import platform import re import shutil +import subprocess import sys import tarfile import tempfile @@ -120,6 +121,31 @@ def _extract(archive: Path, dst: Path) -> None: raise RuntimeError(f"unknown archive format: {archive}") +def _post_extract_darwin(app_root: Path, entry: Path) -> None: + """Make an ad-hoc-signed .app launchable on macOS. + + The .app is downloaded via requests (no Finder quarantine attached), but we + strip com.apple.quarantine defensively and ensure the inner binary is + executable. We exec the inner binary directly (not via LaunchServices), so + Gatekeeper's first-launch prompt does not apply; the ad-hoc signature + (applied in release.yml) is what lets the arm64 Mach-O run at all. + """ + app = app_root + # walk up to the .app bundle dir if entry points inside it + for parent in entry.parents: + if parent.name.endswith(".app"): + app = parent + break + try: + subprocess.run(["xattr", "-dr", "com.apple.quarantine", str(app)], check=False) + except FileNotFoundError: + pass + try: + entry.chmod(0o755) + except OSError: + pass + + def ensure_binary(version: str = BINARY_VERSION) -> Path: """Return a path to a runnable Firefox executable. Download if needed.""" plat = sys.platform @@ -154,6 +180,9 @@ def ensure_binary(version: str = BINARY_VERSION) -> Path: ) _extract(archive_path, version_dir) + if plat == "darwin": + _post_extract_darwin(version_dir, entry) + if not entry.exists(): raise RuntimeError(f"binary not found after extraction: {entry}") return entry diff --git a/tests/test_constants.py b/tests/test_constants.py index 8d124a7..995fb62 100644 --- a/tests/test_constants.py +++ b/tests/test_constants.py @@ -31,9 +31,16 @@ def test_archive_name_linux(): @pytest.mark.unit -def test_archive_name_unsupported_raises(): +def test_archive_name_macos_arm64(): + name = ARCHIVE_NAME("darwin", "arm64") + assert name.endswith(".tar.gz") + assert "macos-arm64" in name + + +@pytest.mark.unit +def test_archive_name_truly_unsupported_raises(): with pytest.raises(NotImplementedError): - ARCHIVE_NAME("darwin", "arm64") + ARCHIVE_NAME("plan9", "x86_64") @pytest.mark.unit @@ -77,20 +84,18 @@ def test_archive_name_rejects_unsupported_arches(machine): @pytest.mark.unit @pytest.mark.parametrize("machine", ["arm64", "aarch64"]) -def test_archive_name_arm64_not_yet_supported(machine): - """ARM64 is a frequent request (issue #6). Until binaries exist for it, - ARCHIVE_NAME should hard-fail rather than silently degrade. If this test - starts failing because someone shipped ARM64 builds, replace it with the - positive case.""" - with pytest.raises(NotImplementedError): - ARCHIVE_NAME("linux", machine) +def test_archive_name_arm64_supported(machine): + """ARM64 is shipped now (issue #6): both Linux aarch64 and macOS arm64. + ARCHIVE_NAME must map both machine spellings to the canonical -arm64 asset.""" + assert ARCHIVE_NAME("linux", machine) == "firefox-150.0.1-stealth-linux-arm64.tar.gz" + assert ARCHIVE_NAME("darwin", machine) == "firefox-150.0.1-stealth-macos-arm64.tar.gz" @pytest.mark.unit -@pytest.mark.parametrize("platform_key", ["darwin", "freebsd", "cygwin", "openbsd"]) +@pytest.mark.parametrize("platform_key", ["freebsd", "cygwin", "openbsd"]) def test_archive_name_rejects_unsupported_platforms(platform_key): - """Same logic — non-Linux/non-Windows platforms must raise, not silently - pick one of the two.""" + """win32/linux/darwin are supported; other platforms must raise, not + silently pick one of the three.""" with pytest.raises(NotImplementedError, match=platform_key): ARCHIVE_NAME(platform_key, "x86_64") @@ -104,7 +109,7 @@ def test_archive_name_rejects_unsupported_platforms(platform_key): def test_binary_entry_rel_covers_every_supported_platform(): """If ARCHIVE_NAME accepts a platform key, BINARY_ENTRY_REL must declare where the executable lives inside the archive for it.""" - for plat in ["win32", "linux"]: + for plat in ["win32", "linux", "darwin"]: ARCHIVE_NAME(plat, "x86_64") # must not raise assert plat in BINARY_ENTRY_REL, ( f"ARCHIVE_NAME accepts {plat!r} but BINARY_ENTRY_REL has no entry " @@ -118,6 +123,7 @@ def test_binary_entry_rel_extension_matches_platform(): assert BINARY_ENTRY_REL["win32"].endswith(".exe") assert not BINARY_ENTRY_REL["linux"].endswith(".exe") assert BINARY_ENTRY_REL["linux"] == "firefox" + assert BINARY_ENTRY_REL["darwin"].endswith(".app/Contents/MacOS/firefox") # ---- RELEASE_URL_TEMPLATE shape ------------------------------------------- # diff --git a/tests/test_download.py b/tests/test_download.py index b32dced..41096ff 100644 --- a/tests/test_download.py +++ b/tests/test_download.py @@ -418,7 +418,7 @@ def test_github_token_none_when_unset(monkeypatch): # Bonus coverage: unsupported platform raises NotImplementedError before any HTTP @pytest.mark.unit def test_ensure_binary_unsupported_platform_raises(monkeypatch): - monkeypatch.setattr("sys.platform", "darwin") + monkeypatch.setattr("sys.platform", "freebsd") # win32/linux/darwin are supported import platform monkeypatch.setattr(platform, "machine", lambda: "AMD64") with pytest.raises(NotImplementedError, match="unsupported platform"):