diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebf7ef8..29d20c5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -181,19 +181,19 @@ jobs: 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 + # 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 (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 + # 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 @@ -220,6 +220,15 @@ jobs: 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; } @@ -324,6 +333,20 @@ jobs: - 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 @@ -344,6 +367,7 @@ jobs: 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 diff --git a/scripts/ci_drive_gate.py b/scripts/ci_drive_gate.py index e81f95f..f84f3df 100644 --- a/scripts/ci_drive_gate.py +++ b/scripts/ci_drive_gate.py @@ -1,15 +1,26 @@ #!/usr/bin/env python3 -"""CI drive gate — the firefox-8 catcher. +"""CI drive gate — the firefox-N catcher. A raw `firefox --screenshot` proves nothing about automation: a juggler-less binary renders a screenshot just fine and ships broken (firefox-8 did exactly that). This DRIVES the binary the way users will — Playwright launches it over -the juggler pipe, loads a real page, and round-trips JS. A binary with a -missing/broken juggler throws TargetClosedError here and the gate fails. +the juggler pipe and exercises the input/DOM paths real callers depend on. -Headless, NO screenshot → GPU-free, so it can't false-fail on GPU-less hosted -runners. Zero proxy / zero secrets → safe in public CI. (The proxy realness -gate — fppro/webrtc — stays local, it needs secrets.) +It deliberately covers the failure modes that HISTORICALLY shipped green: + - juggler missing entirely → TargetClosedError on launch (firefox-8) + - mouse/keyboard input broken → click/move/type assertions (firefox-2 #9: + jugglerSendMouseEvent / synthesizeMouseEvent) + - cross-origin iframe broken → content_frame() reachable (issue #20) + - canvas non-deterministic → identical draw → identical dataURL (stealth + seed must be per-session, not per-readback) + - headless navigator tells → navigator.webdriver falsy, languages + non-empty, plugins is a real PluginArray + +All of this is headless, NO screenshot → GPU-free (can't false-fail on the +GPU-less hosted runners), and fully offline — data: URLs only, NO network, NO +proxy, NO secrets → safe in public CI. WebGL determinism is intentionally NOT +checked here (it needs SWGL and can false-fail headless); it lives in the local +proxy realness gate, alongside the fingerprint/WebRTC-vs-vanilla checks. Usage: python ci_drive_gate.py /path/to/firefox[.exe | .app/Contents/MacOS/firefox] Exit 0 + "DRIVE GATE OK ..." on success; non-zero with a reason on failure. @@ -20,25 +31,82 @@ import sys from playwright.sync_api import sync_playwright +# Single offline page that wires up every probe: a clickable button, a text +# input, a same-document iframe, and a mousemove counter. data: URLs execute +# inline scripts and are same-origin, so this needs no server. +PAGE = ( + "data:text/html," + "dt" + "

hello-drive

" + "" + "" + "" + "" +) + +# Identical 2D draw, evaluated twice in one session. The stealth canvas spoof is +# seeded per-session (see fingerprint-consistency rule), so two identical draws +# MUST produce byte-identical output. Per-readback noise → instant bot flag. +CANVAS_DRAW = ( + "() => {const c=document.createElement('canvas');c.width=c.height=16;" + "const g=c.getContext('2d');g.fillStyle='#08f';g.fillRect(0,0,16,16);" + "g.fillStyle='#f40';g.fillText('s',2,12);return c.toDataURL();}" +) + def main(exe: str) -> int: with sync_playwright() as p: browser = p.firefox.launch(executable_path=exe, headless=True) page = browser.new_page() - # data: URL → real HTML parse + DOM + JS, fully offline (no network/proxy). - page.goto("data:text/html,dt

hello-drive

") + page.goto(PAGE) + ua = page.evaluate("navigator.userAgent") webdriver = page.evaluate("navigator.webdriver") text = page.evaluate("() => document.getElementById('x').textContent") + + # firefox-2 / issue-#9 catcher: real mouse + keyboard over juggler. + page.mouse.move(20, 20) + page.mouse.move(120, 90) # exercises synthesizeMouseEvent path + page.click("#b") # mousedown/up/click → onclick fires + page.click("#inp") + page.keyboard.type("ok") + clicked = page.evaluate("window.__clicked") + moves = page.evaluate("window.__moves") + typed = page.evaluate("() => document.getElementById('inp').value") + + # issue-#20 catcher: the iframe must be reachable and runnable. + frame_el = page.query_selector("#f") + frame = frame_el.content_frame() if frame_el else None + iframe_ok = bool(frame) and frame.evaluate( + "() => document.getElementById('ok') && document.getElementById('ok').textContent" + ) == "ok" + + # stealth-determinism catcher: identical draw → identical dataURL. + canvas_a = page.evaluate(CANVAS_DRAW) + canvas_b = page.evaluate(CANVAS_DRAW) + + # BotD navigator-surface tells (proxy-free subset). + langs = page.evaluate("navigator.languages.length") + plugins = page.evaluate("navigator.plugins instanceof PluginArray") + browser.close() assert "Firefox" in ua, f"unexpected UA (binary not driving correctly): {ua!r}" assert text == "hello-drive", f"DOM/JS roundtrip failed: {text!r}" - # Free stealth smoke: the patched build hides navigator.webdriver even when - # driven by bare Playwright. A True here is a stealth regression, not a crash. assert not webdriver, f"navigator.webdriver leaked True (stealth regression): {webdriver!r}" + assert clicked == 1, "page.click() did not fire onclick — mouse-event synthesis broken (firefox-2 class)" + assert moves >= 1, "page.mouse.move() produced no mousemove — jugglerSendMouseEvent regression" + assert typed == "ok", f"page.keyboard.type() failed: {typed!r}" + assert iframe_ok, "iframe content_frame() unreachable — fission.webContentIsolationStrategy regression (issue #20)" + assert canvas_a == canvas_b, "canvas non-deterministic across identical draws (stealth seed broken → bot tell)" + assert langs and langs > 0, "navigator.languages empty (headless tell)" + assert plugins, "navigator.plugins is not a PluginArray (headless tell)" - print(f"DRIVE GATE OK | UA={ua} | webdriver={webdriver} | dom-roundtrip=ok") + print( + f"DRIVE GATE OK | UA={ua} | webdriver={webdriver} | " + f"click+mousemove+keyboard+iframe+canvas-determinism+navsurface=ok" + ) return 0