Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 71 additions & 23 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,30 @@ name: release
# registered as the trusted publisher, so the upload step cannot be moved into
# another workflow without breaking the OIDC match.
#
# Two entry points:
# Three entry points:
# * push to main -> release-please maintains a release PR; when that PR is
# merged, release-please bumps the version, tags, and cuts
# a GitHub Release. The same run then builds and publishes.
# * workflow_dispatch -> a TestPyPI-only dry run. Builds a throwaway
# * workflow_dispatch (no tag) -> a TestPyPI-only dry run. Builds a throwaway
# ``<version>.devNNNN`` so the chain (OIDC -> environment ->
# trusted publisher -> upload) can be exercised without
# cutting a real release or colliding with an existing one.
# * workflow_dispatch (tag set) -> publish that exact existing tag. Used to
# salvage a tag that was cut (release_please succeeded) but
# did NOT publish (e.g. an earlier output-gate misfire).
# Builds from the tagged commit's pyproject.toml verbatim,
# pushes to TestPyPI and PyPI.
on:
push:
branches: [main]
workflow_dispatch:
inputs:
tag:
description: "Existing tag to (re-)publish (e.g. v0.51.0). Leave blank for a TestPyPI dry run."
required: false
default: ""
reason:
description: "Why this manual TestPyPI dry run (audit note only)."
description: "Why this manual dispatch (audit note only)."
required: false
default: "manual TestPyPI dry run"

Expand All @@ -34,35 +43,58 @@ concurrency:
cancel-in-progress: false

jobs:
# 1. release-please: the only job that runs on an ordinary push to main.
# It keeps the release PR in sync and, on merge, creates the tag/release.
# 1. release-please: only runs on push to main (and is a no-op on tag-replay
# dispatches). It keeps the release PR in sync and, on merge, creates the
# tag/release.
release-please:
name: Release PR / tag
if: ${{ github.event_name == 'push' }}
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.rp.outputs['.--release_created'] }}
tag_name: ${{ steps.rp.outputs['.--tag_name'] }}
version: ${{ steps.rp.outputs['.--version'] }}
# Single-root manifest config: release-please-action v4 exposes a
# top-level ``release_created`` boolean that's "true" exactly when a
# release was cut on this run, alongside path-prefixed keys for
# multi-package configs. The path-prefixed access
# (``outputs['.--release_created']``) silently evaluates to empty for a
# single-root config -- that latent bug never fired here only because
# the engine's first publish (v0.50.3) went out via workflow_dispatch and
# bypassed this gate. The release-please push path needs the top-level
# key to publish.
release_created: ${{ steps.rp.outputs.release_created }}
tag_name: ${{ steps.rp.outputs.tag_name }}
version: ${{ steps.rp.outputs.version }}
steps:
- uses: googleapis/release-please-action@v4
id: rp
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json

# 2. build: runs on a real release (release_created) OR a manual dry run.
# 2. build: runs on a real release (release_created) OR any dispatch.
# Produces a single sdist+wheel artifact consumed by both publish jobs so
# the exact same bytes go to TestPyPI and (for stable releases) PyPI.
build:
name: Build distribution
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' || github.event_name == 'workflow_dispatch' }}
# ``needs.release-please`` is skipped on a workflow_dispatch (the job's
# own ``if`` excludes it), and ``always()`` lets us still run as long as
# we're on a release path that justifies a build.
if: |
always() && (
github.event_name == 'workflow_dispatch'
|| needs.release-please.outputs.release_created == 'true'
)
runs-on: ubuntu-latest
outputs:
version: ${{ steps.ver.outputs.version }}
is_prerelease: ${{ steps.ver.outputs.is_prerelease }}
publish_pypi: ${{ steps.ver.outputs.publish_pypi }}
steps:
- uses: actions/checkout@v4
with:
# For a tag-replay dispatch we want the tagged commit's
# pyproject.toml verbatim, not whatever HEAD is on main.
ref: ${{ github.event.inputs.tag || github.ref }}
- uses: astral-sh/setup-uv@v6
with:
enable-cache: true
Expand All @@ -72,23 +104,30 @@ jobs:
run: |
set -euo pipefail
BASE=$(python3 -c "import tomllib;print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
INPUT_TAG="${{ github.event.inputs.tag }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -z "$INPUT_TAG" ]; then
# Dry run: never collide with an existing TestPyPI version, never
# look like a real release. Only the project version line is
# anchored at column 0, so this rewrites exactly that one line.
VER="${BASE}.dev$(date +%Y%m%d%H%M%S)"
sed -i -E "s/^version = \".*\"/version = \"${VER}\"/" pyproject.toml
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "publish_pypi=false" >> "$GITHUB_OUTPUT"
else
# Real release: trust release-please's bump already on main HEAD.
# Real release (release-please push) OR tag replay (dispatch with
# tag input): trust the checked-out pyproject.toml verbatim.
VER="$BASE"
case "$VER" in
*a*|*b*|*rc*|*dev*|*post*) echo "is_prerelease=true" >> "$GITHUB_OUTPUT" ;;
*) echo "is_prerelease=false" >> "$GITHUB_OUTPUT" ;;
*a*|*b*|*rc*|*dev*|*post*)
echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
echo "publish_pypi=false" >> "$GITHUB_OUTPUT" ;;
*)
echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
echo "publish_pypi=true" >> "$GITHUB_OUTPUT" ;;
esac
fi
echo "version=$VER" >> "$GITHUB_OUTPUT"
echo "Building version $VER"
echo "Building version $VER (publish_pypi=$(grep ^publish_pypi $GITHUB_OUTPUT | cut -d= -f2))"
- name: Build
run: uv build
- name: Show artifacts
Expand All @@ -99,12 +138,17 @@ jobs:
path: dist/
if-no-files-found: error

# 3. TestPyPI: always runs after a successful build (dry run or real release).
# skip-existing keeps a re-run idempotent instead of hard-failing on a
# version that is already on the index.
# 3. TestPyPI: always runs after a successful build (dry run or real release
# or tag replay). ``always() && build == success`` is required because
# GitHub Actions' implicit ``success()`` gate evaluates the *transitive*
# needs graph, so a skipped ``release-please`` (workflow_dispatch path)
# cascades a skip through ``build`` even when build itself succeeded.
# ``skip-existing`` keeps a re-run idempotent instead of hard-failing on
# a version that is already on the index.
testpypi:
name: Publish to TestPyPI
needs: build
if: ${{ always() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
environment: testpypi
permissions:
Expand All @@ -119,13 +163,15 @@ jobs:
repository-url: https://test.pypi.org/legacy/
skip-existing: true

# 4. PyPI: only for a real, non-prerelease release. The `pypi` environment
# carries the manual-approval gate and is matched by the prod trusted
# publisher (configure both before the first stable tag).
# 4. PyPI: stable releases only. Driven by the build's own ``publish_pypi``
# output so both the release-please path and a tag-replay dispatch share
# the same decision rule (non-prerelease version => publish). The
# ``always() && build == success`` clause is the same skip-cascade
# countermeasure as on ``testpypi``.
pypi:
name: Publish to PyPI
needs: [release-please, build]
if: ${{ needs.release-please.outputs.release_created == 'true' && needs.build.outputs.is_prerelease == 'false' }}
needs: build
if: ${{ always() && needs.build.result == 'success' && needs.build.outputs.publish_pypi == 'true' }}
runs-on: ubuntu-latest
environment: pypi
permissions:
Expand All @@ -136,3 +182,5 @@ jobs:
name: dist
path: dist/
- uses: pypa/gh-action-pypi-publish@release/v1
with:
skip-existing: true
4 changes: 4 additions & 0 deletions mithwire/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from mithwire.core.element import Element
from mithwire.core.tab import Tab
from mithwire.core.util import loop, start
from mithwire.stealth import FingerprintConfig, Stealth, compute_launch_args

__all__ = [
"loop",
Expand All @@ -27,6 +28,9 @@
"Element",
"ContraDict",
"ProtocolException",
"FingerprintConfig",
"Stealth",
"compute_launch_args",
]

__version__ = "0.50.3"
2 changes: 1 addition & 1 deletion mithwire/cdp/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -1342,7 +1342,7 @@ class Cookie:
#: Cookie expiration date as the number of seconds since the UNIX epoch.
#: The value is set to -1 if the expiry date is not set.
#: The value can be null for values that cannot be represented in
#: JSON (±Inf).
#: JSON (±Inf).
expires: typing.Optional[float] = None

#: Cookie SameSite type.
Expand Down
38 changes: 38 additions & 0 deletions mithwire/core/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ def __init__(self, config: Config, **kwargs):
self._keep_user_data_dir = None
self._is_updating = asyncio.Event()
self.connection: Connection = None
self.stealth = None
super().__init__("", auto_attach=False)
logger.debug("Session object initialized: %s" % vars(self))

Expand Down Expand Up @@ -410,8 +411,45 @@ async def start(self=None) -> Browser:
self.websocket_url = self.info.webSocketDebuggerUrl
await self.attach()
await self.update_targets()
await self._apply_stealth()
# await self

async def _apply_stealth(self) -> None:
"""Apply the engine-owned anti-detect stealth to the live browser.

The engine owns every browser-altering anti-detect capability, so this
runs on every launch. With no configured identity it still applies the
always-on baseline (window.chrome shim, headless UA cleanup when
headless, WebRTC leak protection when proxied). The resulting
:class:`~mithwire.stealth.Stealth` is stored on ``self.stealth`` so a
client can re-apply an identity later (e.g. once a proxy egress geo is
resolved).
"""
from ..stealth import Stealth

config = self.config
# The engine is agnostic of any client's proxy abstraction: proxy
# presence is inferred purely from the launch flags.
proxied = any(
str(arg).startswith("--proxy-server=")
for arg in (getattr(config, "_browser_args", None) or [])
)
stealth = Stealth(
self,
fingerprint=getattr(config, "fingerprint", None),
webrtc_leak_protection=getattr(config, "webrtc_leak_protection", "auto"),
headless=bool(getattr(config, "headless", False)),
proxied=proxied,
)
# A freshly attached tab needs a brief moment before CDP overrides and
# new-document scripts reliably register on the about:blank target.
await asyncio.sleep(1.2)
try:
await stealth.apply_all()
except Exception as exc: # noqa: BLE001
logger.warning("Anti-detect stealth application failed: %s", exc)
self.stealth = stealth

async def grant_all_permissions(self):
"""
grant permissions for:
Expand Down
25 changes: 25 additions & 0 deletions mithwire/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ def __init__(
host: str = AUTO,
port: int = AUTO,
expert: bool = AUTO,
fingerprint: Optional[object] = None,
webrtc_leak_protection: str = "auto",
**kwargs: dict,
):
"""
Expand Down Expand Up @@ -110,6 +112,17 @@ def __init__(
self.autodiscover_targets = True
self.lang = lang

# Anti-detect stealth identity. The engine owns all browser-altering
# anti-detect code; a client only describes the identity it wants here.
# ``fingerprint`` may be a FingerprintConfig or a plain dict (normalized).
from ..stealth import FingerprintConfig

if fingerprint is None or isinstance(fingerprint, FingerprintConfig):
self.fingerprint = fingerprint
else:
self.fingerprint = FingerprintConfig.from_dict(fingerprint)
self.webrtc_leak_protection = webrtc_leak_protection

# other keyword args will be accessible by attribute
self.__dict__.update(kwargs)
super().__init__()
Expand All @@ -127,6 +140,18 @@ def __init__(
"--disable-session-crashed-bubble",
"--disable-search-engine-choice-screen",
]
# Stealth launch flags that must be set before the process starts
# (--lang, --force-webrtc-ip-handling-policy, headless window size).
# These cannot be retrofitted leak-free via CDP on a running process.
from ..stealth import compute_launch_args

for arg in compute_launch_args(
self._browser_args,
fingerprint=self.fingerprint,
headless=self.headless,
):
if arg not in self._browser_args:
self._browser_args.append(arg)

@property
def browser_args(self):
Expand Down
4 changes: 4 additions & 0 deletions mithwire/core/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ async def start(
host: Optional[str] = None,
port: Optional[int] = None,
expert: Optional[bool] = None,
fingerprint: Optional[object] = None,
webrtc_leak_protection: str = "auto",
**kwargs: Optional[dict],
) -> Browser:
"""
Expand Down Expand Up @@ -96,6 +98,8 @@ async def start(
host=host,
port=port,
expert=expert,
fingerprint=fingerprint,
webrtc_leak_protection=webrtc_leak_protection,
**kwargs,
)
from .browser import Browser
Expand Down
Loading
Loading