Skip to content

fix(http2): wire custom HTTP/2 fingerprints to the transport - #85

Closed
smeinecke wants to merge 150 commits into
Danny-Dasilva:mainfrom
smeinecke:fix-http2-fingerprint
Closed

fix(http2): wire custom HTTP/2 fingerprints to the transport#85
smeinecke wants to merge 150 commits into
Danny-Dasilva:mainfrom
smeinecke:fix-http2-fingerprint

Conversation

@smeinecke

Copy link
Copy Markdown
Contributor

Summary

  • Fix http2_fingerprint so the supplied HTTP/2 identity is actually emitted on the wire.
  • Rewrite golang/http2.go to parse the canonical SETTINGS|WINDOW_UPDATE|PRIORITY_FRAMES|PSEUDO_HEADERS format and populate fhttp's http2.HTTP2Settings.
  • Update golang/roundtripper.go to apply those settings to the transport and to set the pseudo-header order from the fingerprint.

Background

The Go layer parsed http2_fingerprint but only assigned it to http2.Transport.Settings. That field is overwritten by Transport.ApplyPreset, which derives the real HTTP/2 frame profile from the Navigator (parsed from the User-Agent). As a result, every request was emitted with the default Firefox- or Chrome-style frame sequence regardless of the supplied http2_fingerprint.

What changed

  • HTTP2Fingerprint now carries the full fingerprint:
    • Settings from the first part
    • ConnectionFlow from the second part
    • PriorityFrames from the third part
    • HeaderPriority (reserved for future use)
    • PriorityOrder pseudo-header map from the fourth part
  • Apply sets http2.Transport.HTTP2Settings instead of just Settings.
  • roundTripper parses the fingerprint once and uses it when creating the http2.Transport and when preparing each request (http.PHeaderOrderKey).
  • HTTP2Fingerprint.Navigator() infers the minimal fhttp browser default needed for the implicit HEADERS stream ID when no explicit priority frames are present.

Test plan

  • go test ./... in golang/ passes.
  • Built a fresh libcycletls and verified with the matrix of Firefox/Chrome/Edge/custom http2_fingerprint strings that the wire Akamai fingerprint now matches the supplied value.
  • Verified named profiles (firefox_124_win, chrome_149_*) now produce coherent HTTP/2 frames.
  • Confirmed the pseudo-header order (m,p,a,s vs m,a,s,p) follows the fingerprint, not just the User-Agent.

Notes

This does not change how ja3 / ja4r TLS fingerprints are handled; it only makes http2_fingerprint an independent, working lever. Profiles that already ship both ja4r and http2_fingerprint now produce a matching TLS + HTTP/2 identity.

smeinecke and others added 30 commits March 22, 2026 13:14
…flow permissions

- Use debugLogger instead of log for request headers/body to avoid clear-text logging of sensitive data
- Add bounds check after strconv.Atoi in parseError to prevent unsafe int→uint8 narrowing
- Add permissions: contents: read to all workflow jobs missing explicit GITHUB_TOKEN permissions
* feat: add Python 3.9 support and follow-up fixes
…les, and CI capture pipeline

- Add BrowserFamily and Platform enums for typed registry lookups
- Add FingerprintRegistry.by_family(family, platform=None), latest(), random()
  with optional family/platform filters; refactor shared _candidates() helper
- Rename all built-in profiles in fingerprints.json with platform suffix derived
  from user-agent (_win/_linux/_mac/_android); update _PROFILE_EXPORTS accordingly
- Add _platform_suffix() to capture script; all new Playwright captures get
  OS-tagged names (e.g. chrome_146_0_0_0_linux, chrome_146_0_0_0_win)
- Add Android ADB capture mode (--android-only) and chrome_*_android profiles
- Add GitHub Actions fingerprint-tests workflow: Playwright capture across
  Linux/macOS/Windows, Android emulator capture, fingerprint replication tests
- Add make fingerprint-tests target for local end-to-end runs
- Add full unit test coverage for new enum/registry methods (TestBrowserFamilyAndPlatform)
pw.android is a JS-only Playwright API not exposed in the Python SDK.
Replace with:
- subprocess.run(["adb", "devices"]) to discover connected devices
- adb shell am start to launch Chrome on the device
- adb forward tcp:9222 localabstract:chrome_devtools_remote for CDP
- pw.chromium.connect_over_cdp() + Security.setIgnoreCertificateErrors
  via CDP session to navigate and capture the fingerprint

Also switch the CI emulator target from "default" to "google_apis" since
the default AOSP image does not include Chrome.
…container

Avoids re-downloading Chrome (~200 MB) on every run and the headed-Chrome
hang inside xvfb-run. The host runner already has chromium/chrome/firefox/
webkit installed from the previous Install step. TrackMe is port-mapped to
localhost:8443 so --url https://localhost:8443 reaches it directly.
Bumps [github.com/andybalholm/brotli](https://github.com/andybalholm/brotli) from 1.2.0 to 1.2.1.
- [Commits](andybalholm/brotli@v1.2.0...v1.2.1)

---
updated-dependencies:
- dependency-name: github.com/andybalholm/brotli
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
updated-dependencies:
- dependency-name: aiohttp
  dependency-version: 3.13.4
  dependency-type: direct:production
  dependency-group: uv
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
- actions/checkout@v4 → v6
- actions/setup-python@v5 → v6  
- actions/setup-go@v5 → v6
- actions/upload-artifact@v4 → v7
- actions/download-artifact@v4 → v8
New fingerprints: chromium_145_0_0_0_mac,chromium_145_0_0_0_win
Bumps [peter-evans/create-pull-request](https://github.com/peter-evans/create-pull-request) from 7 to 8.
- [Release notes](https://github.com/peter-evans/create-pull-request/releases)
- [Commits](peter-evans/create-pull-request@v7...v8)

---
updated-dependencies:
- dependency-name: peter-evans/create-pull-request
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.69.0 to 1.70.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](valyala/fasthttp@v1.69.0...v1.70.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.70.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.52.0 to 0.53.0.
- [Commits](golang/net@v0.52.0...v0.53.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.53.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
dependabot Bot and others added 26 commits August 24, 2026 07:55
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.6 to 4.37.8.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](github/codeql-action@v4.37.6...v4.37.8)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: 4.37.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
New fingerprints: chrome_152_0_0_0_linux,chrome_152_0_0_0_mac,chrome_152_0_0_0_win
New fingerprints: msedge_152_0_0_0_linux,msedge_152_0_0_0_mac,msedge_152_0_0_0_win
…lang.org/x/net-0.58.0

build(deps): bump golang.org/x/net from 0.57.0 to 0.58.0 in /golang
…l-sh/setup-uv-10.0.1

build(deps): bump astral-sh/setup-uv from 9.0.0 to 10.0.1
…b/codeql-action-4.37.8

build(deps): bump github/codeql-action from 4.37.6 to 4.37.8
Request.to_dict() was clearing `ja3` whenever `ja4r`,
`http2_fingerprint`, or `quic_fingerprint` was set. This caused
`fingerprint="..."` to fall back to the Go default Chrome JA3 for
most built-in profiles. Only `ja4r` should take precedence over `ja3`;
http2/quic are independent layers.

- Change `Request.ja3` default from a hard-coded Firefox 65 string to
  `Optional[str] = None` so a missing/unspecified ja3 lets the Go
  backend use its own default.
- Only include `ja3` in the payload when it is explicitly set and
  `ja4r` is not present.
- Sync `cycletls/__version__.py` and `pyproject.toml` to 0.0.9.
- Update CHANGELOG with the schema fix and merged dependency bumps.
- Refresh `uv.lock` for the new package version.
- Correct the 0.0.8 entry to only the Brotli fix and its regression test.
- Expand 0.0.9 with the actual schema fix, dependency bumps, and fingerprint
  registry updates from `v0.0.8..v0.0.9`.
- Add missing 0.0.7 and 0.0.6 sections; move the `local_address`, `Do()`,
  `TLS13AutoRetry`, and `dispatchSSEAsync` items from 0.0.8 to 0.0.6 where
  they were actually introduced.
`scripts/capture_browser_fingerprints.py` only recorded `header_order`
but dropped the actual header values. Newly captured profiles therefore
lost the `headers` dictionary (e.g. default `accept`, `accept-language`,
`accept-encoding`, `sec-fetch-*`) that older registry entries had.

- Parse both header name order and header values from HTTP/2 HEADERS
  frames in `_parse_sent_headers`.
- Return both from `capture_fingerprint` and `_capture_android_cdp` so
  the output JSON contains a `headers` map.
- Pass `headers` through `_normalize_trackme_payload` so
  `load_trackme_fingerprints` persists them in the registry.

This restores parity between newly captured profiles and manually
maintained ones in `cycletls/data/fingerprints.json`.
… headers

Add CDP support to `scripts/capture_browser_fingerprints.py` so it can
connect to versioned browser containers (e.g. Selenium standalone Chrome)
instead of only launching Playwright-managed browsers. Detect browser and
platform from the user-agent, sanitize dynamic `sec-ch-ua`/`user-agent`
values from headers, and force `en-US` locale for deterministic
`Accept-Language`.

Add `scripts/backfill_headers.py` to merge captured `headers` and
`header_order` into `cycletls/data/fingerprints.json`, matching by
browser/version prefix so one Linux capture can backfill the Windows and
macOS variants.

Backfill `headers` for the `chrome_152_0_0_0_*` profiles from a live
Chrome 152 CDP capture. Remaining missing headers can be filled the same
way from appropriate browser Docker images.
Run `scripts/backfill_headers.py` after merging new captured fingerprints
so the registry auto-backfills `headers` for matching browser/version
profiles (e.g. a Linux capture also fills the Windows and macOS variants).
Add Selenium/WebDriver capture mode so the script can drive
selenium/standalone-firefox, standalone-chrome, and standalone-edge
containers. Disable built-in JSON viewers (Firefox devtools preference,
Chromium/Edge --disable-blink-features=PrettyPrintJSONDocument) so the
/api/all JSON body can be read directly.
Add `.github/workflows/backfill-headers.yml` which discovers profiles
missing `headers`, runs versioned `selenium/standalone-*` Docker
containers for each Chrome/Chromium/Edge/Firefox base, captures the
headers with `scripts/capture_browser_fingerprints.py --selenium-url`,
and backfills `cycletls/data/fingerprints.json` via
`scripts/backfill_headers.py` in a single PR.

The capture script now also supports `--browser-override` and
`--version-override` so a `standalone-chrome` image can be used to
backfill both `chrome_*` and `chromium_*` registry entries.
Add `selenium` to the dev dependency group so `uv run` can drive
Selenium containers in CI. Update `backfill-headers.yml` to `uv sync`
instead of the broken `uv pip install` and add Docker image version
fallbacks for versions without a standalone image (e.g. Edge 152).
Co-authored-by: smeinecke <7316652+smeinecke@users.noreply.github.com>
Co-authored-by: smeinecke <7316652+smeinecke@users.noreply.github.com>
Co-authored-by: smeinecke <7316652+smeinecke@users.noreply.github.com>
The http2_fingerprint option was parsed but never actually emitted on the
wire. The old HTTP2Fingerprint.Apply only wrote Transport.Settings, which
fhttp's Transport.ApplyPreset immediately overwrote with hardcoded Firefox/
Chrome defaults derived from the User-Agent. The fhttp HTTP2Settings struct
(ConnectionFlow, PriorityFrames, HeaderPriority, and Settings) was left empty,
so connection flow, initial priority frames, and the HEADERS stream ID stayed
driven by the UA regardless of the supplied fingerprint.

Rewrite HTTP2Fingerprint parsing to match the canonical
SETTINGS|WINDOW_UPDATE|PRIORITY_FRAMES|PSEUDO_HEADERS format and populate
http2.HTTP2Settings. Apply that to the transport and use the fingerprint to
pick the remaining fhttp defaults (e.g. the implicit HEADERS stream ID when no
priority frames are present). Also map the pseudo-header order letters onto
http.PHeaderOrderKey so the request header ordering matches the fingerprint too.

This makes http2_fingerprint a first-class option and lets profiles that ship
both ja4r and http2_fingerprint produce a coherent TLS + HTTP/2 identity.
Comment on lines +21 to +203
name: Discover missing headers
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
has_work: ${{ steps.matrix.outputs.has_work }}
docker_matrix: ${{ steps.matrix.outputs.docker_matrix }}
docker_has_work: ${{ steps.matrix.outputs.docker_has_work }}
playwright_matrix: ${{ steps.matrix.outputs.playwright_matrix }}
playwright_has_work: ${{ steps.matrix.outputs.playwright_has_work }}
android_matrix: ${{ steps.matrix.outputs.android_matrix }}
android_has_work: ${{ steps.matrix.outputs.android_has_work }}
steps:
- uses: actions/checkout@v7

- name: Generate capture matrix
id: matrix
run: |
python - <<'EOF'
import json, re, os

# Known selenium/standalone-* image versions available on Docker Hub.
# Used to fall back to a nearby image when an exact version is not published.
KNOWN_IMAGE_VERSIONS = {
"chrome": ["151.0", "150.0", "149.0", "148.0", "147.0"],
"edge": ["151.0", "150.0", "149.0", "148.0", "147.0"],
"firefox": ["153.0", "152.0", "151.0", "150.0", "149.0", "148.0"],
}

# Map a Safari/webkit version to the Playwright release that ships it.
# Playwright controls the exact browser build it uses, so we must pin it.
PLAYWRIGHT_VERSIONS = {
"26.0": "1.58.0",
"26.4": "1.60.0",
"26.5": "1.61.0",
}

def image_for(image_browser: str, tag_version: str) -> str:
known = KNOWN_IMAGE_VERSIONS.get(image_browser, [])
# Prefer an exact major.minor match.
if tag_version in known:
return f"selenium/standalone-{image_browser}:{tag_version}-20260808"
# Fall back to the closest older version that is known to exist.
parts = tag_version.split(".")
major = int(parts[0])
minor = int(parts[1]) if len(parts) > 1 else 0
candidates = [v for v in known]
candidates.sort(key=lambda v: tuple(int(x) for x in v.split(".")), reverse=True)
for cand in candidates:
cmajor, cminor = (int(x) for x in cand.split("."))
if (cmajor, cminor) <= (major, minor):
return f"selenium/standalone-{image_browser}:{cand}-20260808"
if known:
return f"selenium/standalone-{image_browser}:{known[0]}-20260808"
return f"selenium/standalone-{image_browser}:nightly"

def safe_name(base: str) -> str:
return re.sub(r"[^0-9A-Za-z]+", "-", base).strip("-")

registry_path = "cycletls/data/fingerprints.json"
registry = json.load(open(registry_path))
missing = [fp for fp in registry["fingerprints"] if "headers" not in fp]

seen = set()
docker_include = []
playwright_include = []
android_include = []

for fp in missing:
name = fp["name"]
parts = name.rsplit("_", 1)
base = parts[0] if len(parts) == 2 else name
if base in seen:
continue
seen.add(base)

ua = fp.get("user_agent", "")
m = re.search(r"(?:Chrome|Chromium|HeadlessChrome|Firefox|Edg|Version|wv)/([0-9.]+)", ua)
version = m.group(1) if m else "latest"

if base.startswith("safari_"):
tag_version = ".".join(version.split(".")[:2])
playwright_version = PLAYWRIGHT_VERSIONS.get(tag_version, "1.61.0")
playwright_include.append({
"base": base,
"version": version,
"playwright_version": playwright_version,
"artifact": f"capture-{safe_name(base)}",
})
elif base.startswith("chrome_android_"):
android_include.append({
"base": base,
"version": version,
"artifact": f"capture-{safe_name(base)}",
})
elif base.startswith("chrome_"):
image_browser = "chrome"
selenium_browser = "chrome"
registry_browser = "chrome"
tag_version = ".".join(version.split(".")[:2])
image = image_for(image_browser, tag_version)
docker_include.append({
"base": base,
"image": image,
"selenium_browser": selenium_browser,
"registry_browser": registry_browser,
"version": version,
"artifact": f"capture-{safe_name(base)}",
})
elif base.startswith("chromium_"):
image_browser = "chrome"
selenium_browser = "chrome"
registry_browser = "chromium"
tag_version = ".".join(version.split(".")[:2])
image = image_for(image_browser, tag_version)
docker_include.append({
"base": base,
"image": image,
"selenium_browser": selenium_browser,
"registry_browser": registry_browser,
"version": version,
"artifact": f"capture-{safe_name(base)}",
})
elif base.startswith("msedge_"):
image_browser = "edge"
selenium_browser = "MicrosoftEdge"
registry_browser = "msedge"
tag_version = ".".join(version.split(".")[:2])
image = image_for(image_browser, tag_version)
docker_include.append({
"base": base,
"image": image,
"selenium_browser": selenium_browser,
"registry_browser": registry_browser,
"version": version,
"artifact": f"capture-{safe_name(base)}",
})
elif base.startswith("firefox_"):
image_browser = "firefox"
selenium_browser = "firefox"
registry_browser = "firefox"
tag_version = ".".join(version.split(".")[:2])
image = image_for(image_browser, tag_version)
docker_include.append({
"base": base,
"image": image,
"selenium_browser": selenium_browser,
"registry_browser": registry_browser,
"version": version,
"artifact": f"capture-{safe_name(base)}",
})
else:
print(f"[skip] unsupported missing profile: {base}")
continue

docker_matrix = {"include": docker_include}
playwright_matrix = {"include": playwright_include}
android_matrix = {"include": android_include}

docker_has_work = "true" if docker_include else "false"
playwright_has_work = "true" if playwright_include else "false"
android_has_work = "true" if android_include else "false"
has_work = "true" if (docker_include or playwright_include or android_include) else "false"

with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"matrix={json.dumps(docker_matrix)}\n")
fh.write(f"docker_matrix={json.dumps(docker_matrix)}\n")
fh.write(f"docker_has_work={docker_has_work}\n")
fh.write(f"playwright_matrix={json.dumps(playwright_matrix)}\n")
fh.write(f"playwright_has_work={playwright_has_work}\n")
fh.write(f"android_matrix={json.dumps(android_matrix)}\n")
fh.write(f"android_has_work={android_has_work}\n")
fh.write(f"has_work={has_work}\n")

print(f"Generated capture jobs: {len(docker_include)} docker, {len(playwright_include)} playwright, {len(android_include)} android")
for item in docker_include:
print(f" docker: {item['base']}: {item['image']} -> {item['registry_browser']} {item['version']}")
for item in playwright_include:
print(f" playwright: {item['base']}: playwright=={item['playwright_version']} -> {item['version']}")
for item in android_include:
print(f" android: {item['base']}: {item['version']}")
EOF

capture-docker:
Comment on lines +204 to +263
name: Capture ${{ matrix.base }} headers
needs: discover
runs-on: ubuntu-latest
if: needs.discover.outputs.docker_has_work == 'true'
continue-on-error: true
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.discover.outputs.docker_matrix) }}
steps:
- uses: actions/checkout@v7

- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Setup uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true

- name: Start Selenium container
run: |
docker run -d --name selenium-${{ matrix.base }} -p 4444:4444 --shm-size=2g ${{ matrix.image }}
for i in $(seq 1 60); do
if curl -sf http://localhost:4444/wd/hub/status | grep -q '"ready": true'; then
echo "Selenium ready after $((i*2))s"
break
fi
sleep 2
done

- name: Install Python dependencies
run: uv sync --locked --dev --no-install-project

- name: Capture fingerprint
env:
SERVER_URL: ${{ github.event.inputs.server_url }}
run: |
uv run --no-project python scripts/capture_browser_fingerprints.py \
--selenium-url http://localhost:4444/wd/hub \
--selenium-browser ${{ matrix.selenium_browser }} \
--browser-override ${{ matrix.registry_browser }} \
--version-override ${{ matrix.version }} \
--url "$SERVER_URL" \
--output /tmp/capture.json

- name: Upload capture artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: /tmp/capture.json
if-no-files-found: warn

- name: Stop Selenium container
if: always()
run: docker stop selenium-${{ matrix.base }} || true; docker rm -f selenium-${{ matrix.base }} || true

capture-playwright:
Comment on lines +264 to +311
name: Capture ${{ matrix.base }} headers (Playwright)
needs: discover
runs-on: ubuntu-latest
if: needs.discover.outputs.playwright_has_work == 'true'
continue-on-error: true
timeout-minutes: 25
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.discover.outputs.playwright_matrix) }}
steps:
- uses: actions/checkout@v7

- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Setup uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true

- name: Install Python dependencies
run: uv sync --locked --dev --no-install-project

- name: Install Playwright WebKit
run: |
uv pip install --force-reinstall playwright==${{ matrix.playwright_version }}
uv run --no-project python -m playwright install --with-deps webkit

- name: Capture fingerprint
env:
SERVER_URL: ${{ github.event.inputs.server_url }}
run: |
uv run --no-project python scripts/capture_browser_fingerprints.py \
--require-browsers safari \
--url "$SERVER_URL" \
--output /tmp/capture.json

- name: Upload capture artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: /tmp/capture.json
if-no-files-found: warn

capture-android:
Comment on lines +312 to +372
name: Capture ${{ matrix.base }} headers (Android)
needs: discover
runs-on: ubuntu-latest
if: needs.discover.outputs.android_has_work == 'true'
continue-on-error: true
timeout-minutes: 45
strategy:
fail-fast: false
matrix: ${{ fromJSON(needs.discover.outputs.android_matrix) }}
steps:
- uses: actions/checkout@v7

- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Setup uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true

- name: Install Python dependencies
run: uv sync --locked --dev --no-install-project

- name: Install Playwright Chromium
run: uv run --no-project python -m playwright install --with-deps chromium

- name: Start Android emulator and capture Chrome fingerprint
uses: ReactiveCircus/android-emulator-runner@v2
with:
api-level: 34
arch: x86_64
target: google_apis
profile: pixel_7
emulator-boot-timeout: 300
script: |
set -eux
echo "==> Emulator booted. Waiting 30 s for Play Services to settle..."
sleep 30
echo "==> ADB devices:"
adb devices
echo "==> Chrome package version:"
adb shell dumpsys package com.android.chrome | grep versionName || true
. .venv/bin/activate && python3 scripts/capture_browser_fingerprints.py --android-only --url "${{ github.event.inputs.server_url }}" --output /tmp/capture.json --version-override "${{ matrix.version }}"

- name: Upload capture artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: ${{ matrix.artifact }}
path: /tmp/capture.json
if-no-files-found: warn

merge-and-backfill:
Comment on lines +373 to +425
name: Merge captures and backfill registry
needs: [discover, capture-docker, capture-playwright, capture-android]
runs-on: ubuntu-latest
if: always() && needs.discover.outputs.has_work == 'true'
steps:
- uses: actions/checkout@v7

- name: Setup Python
uses: actions/setup-python@v7
with:
python-version: "3.12"

- name: Setup uv
uses: astral-sh/setup-uv@v10.0.1
with:
enable-cache: true

- name: Install Python dependencies
run: uv sync --locked --dev --no-install-project

- name: Download capture artifacts
uses: actions/download-artifact@v7
with:
path: /tmp/captures
pattern: capture-*

- name: Backfill registry
run: |
for capture_file in $(find /tmp/captures -type f -name capture.json); do
echo "Backfilling from $capture_file"
uv run --no-project python scripts/backfill_headers.py \
--registry cycletls/data/fingerprints.json \
--capture "$capture_file"
done

- name: Show changes
run: |
git diff --stat cycletls/data/fingerprints.json || true

- name: Create backfill PR
if: github.event.inputs.dry_run != 'true'
uses: peter-evans/create-pull-request@v8
with:
add-paths: |
cycletls/data/fingerprints.json
branch: ci/backfill-missing-headers
commit-message: "chore: backfill missing fingerprint headers"
title: "chore: backfill missing fingerprint headers"
body: |
This automated PR backfills `headers` (and `header_order` where missing)
for browser profiles that were missing them, using Selenium/Playwright
and an Android emulator where needed.
delete-branch: true
return True

page_text = xml_text.lower()
if "https://policies.google.com/" in page_text or "google terms of service" in page_text:
Comment thread tests/brotli_server.py
context.load_cert_chain(cert_path, key_path)

with socketserver.TCPServer(("", port), Handler) as httpd:
httpd.socket = context.wrap_socket(httpd.socket, server_side=True)
context.load_cert_chain(cert_path, key_path)

server = ThreadingHTTPServer(("127.0.0.1", 0), TrailingBrotliHandler)
server.socket = context.wrap_socket(server.socket, server_side=True)
Comment thread tests/test_integration.py
if '/html' in url or 'example.com' in url:
assert isinstance(response.body, str), \
f"HTML response should be string for {url}"
if "/html" in url or "example.com" in url:
Comment thread golang/index.go
}
log.Println(headers)
log.Println(body)
debugLogger.Println(headers)
@smeinecke smeinecke closed this Aug 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants