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," + "