Skip to content

chore(deps): update dependency chrome-for-testing to v157 (#1799) #7201

chore(deps): update dependency chrome-for-testing to v157 (#1799)

chore(deps): update dependency chrome-for-testing to v157 (#1799) #7201

Workflow file for this run

name: CI
on:
push:
branches: [main]
pull_request:
# `labeled`/`unlabeled` (beyond the opened/synchronize/reopened defaults) so
# toggling the `full-ci` label re-triggers CI and immediately flips the
# matrix between the subset and the full grid — otherwise the label only
# takes effect on the next push.
#
# `ready_for_review` (also non-default) is the recovery path for issue #509:
# if a `synchronize` ever fails to dispatch this required check (e.g. a merge
# commit pushed under a non-trigger-capable token), marking the PR draft and
# back to ready re-evaluates the head SHA and (re)dispatches — a no-side-effect
# alternative to close+reopen.
types: [opened, synchronize, reopened, labeled, unlabeled, ready_for_review]
# Nightly full sweep on main: catches a portability/CVE/dep regression that a
# trimmed internal-PR matrix would skip, without waiting for the next release.
schedule:
- cron: "27 6 * * *"
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
# --------------------------------------------------------------------
# setup — emit the test matrix + classify the diff. ONE cheap job that
# decides everything downstream:
# • matrix: internal PR → a representative SUBSET (all 4 Pythons + all
# 3 OSes in 4 legs, Windows-weighted since it's the buggiest
# platform, and ubuntu+3.13 always present = the coverage-gate
# and Codecov cell). push:main / nightly cron / fork PRs (after
# approval) / a `full-ci`-labelled PR → the FULL 3×4 = 12 grid.
# Trimming applies ONLY to internal PRs; main + nightly always
# run the full grid (release safety-net — a release is only ever
# cut from a green main).
# • code: true unless the PR touches ONLY docs/meta files. The heavy
# jobs (tests, build, docker) gate on this so a docs-only PR
# skips them entirely. Fail-safe: anything unrecognised, an
# empty diff, or any non-PR event → code=true (never skip on
# main/cron). The `ci required checks passed` aggregator counts a
# skipped job as passing, so the required check still reports.
# DRAFT PRs (#623): EVERY job in this workflow carries
# `github.event.pull_request.draft != true`, so a draft PR runs nothing at
# all — including `setup` and the aggregator. A batch of drafts can be opened
# cheaply and each flipped to "ready for review" one at a time to fire its CI.
# `!= true` (not `== false`) so push:main / cron — where the `draft` field is
# null — fail-safe to running.
#
# The guard used to stop short of `setup` and the aggregator, on the theory
# that the required context must always report. It doesn't need to: GitHub
# reports a job skipped by its `if:` as a check run whose conclusion is
# SUCCESS, so `ci required checks passed` resolves on a draft instead of
# hanging at "Expected — waiting for status" (that failure mode, #196, comes
# from a WORKFLOW-level `paths:`/branch filter, which suppresses the check run
# entirely — not from a job-level `if:`). Guarding the aggregator too is the
# honest reading: a draft now shows the context greyed-out/skipped rather than
# a solid green earned by a run in which zero test cells executed.
#
# `ready_for_review` is already in the pull_request types, so marking a draft
# ready re-dispatches the whole workflow on the current head SHA.
# TRADE-OFF, stated plainly: a draft PR gets NO CI feedback. Push freely while
# drafting; the first real signal arrives when you flip it ready.
# --------------------------------------------------------------------
setup:
name: setup (matrix + change filter)
if: github.event.pull_request.draft != true
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
code: ${{ steps.changes.outputs.code }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Need both PR endpoints in history to diff base..head.
fetch-depth: 0
persist-credentials: false
# Shared classifier (also used by security.yml) — keep the docs/meta
# allowlist in one place.
- name: Classify changed files (code vs docs-only)
id: changes
uses: $/.github/actions/changed-code
- name: Decide matrix (subset vs full)
id: matrix
env:
EVENT_NAME: ${{ github.event_name }}
IS_FORK: ${{ github.event.pull_request.head.repo.fork }}
LABELS: ${{ toJSON(github.event.pull_request.labels.*.name) }}
shell: bash
run: |
set -euo pipefail
# FULL 3×4 grid. macOS/Windows were brought green via a .cmd test-stub
# entrypoint + cross-platform guards; the full sweep keeps a
# version-specific portability bug from hiding on an untested OS×py cell.
full='{"include":[
{"os":"ubuntu-latest","python":"3.11"},
{"os":"ubuntu-latest","python":"3.12"},
{"os":"ubuntu-latest","python":"3.13"},
{"os":"ubuntu-latest","python":"3.14"},
{"os":"macos-latest","python":"3.11"},
{"os":"macos-latest","python":"3.12"},
{"os":"macos-latest","python":"3.13"},
{"os":"macos-latest","python":"3.14"},
{"os":"windows-latest","python":"3.11"},
{"os":"windows-latest","python":"3.12"},
{"os":"windows-latest","python":"3.13"},
{"os":"windows-latest","python":"3.14"}
]}'
# SUBSET for internal PRs: 4 legs covering all 4 Pythons and all 3 OSes.
# ubuntu+3.13 is mandatory (coverage gate + Codecov upload live there);
# Windows gets two legs (our buggiest platform); one macOS leg.
subset='{"include":[
{"os":"ubuntu-latest","python":"3.13"},
{"os":"windows-latest","python":"3.11"},
{"os":"windows-latest","python":"3.14"},
{"os":"macos-latest","python":"3.12"}
]}'
use_full=false
if [ "$EVENT_NAME" != "pull_request" ]; then
use_full=true # push:main, schedule
elif [ "$IS_FORK" = "true" ]; then
use_full=true # fork PR → full gamut
elif printf '%s' "$LABELS" | grep -q '"full-ci"'; then
use_full=true # escape hatch label
fi
if [ "$use_full" = "true" ]; then
echo "Using FULL matrix (event=$EVENT_NAME fork=$IS_FORK)."
echo "matrix=$(printf '%s' "$full" | jq -c .)" >> "$GITHUB_OUTPUT"
else
echo "Using SUBSET matrix (internal PR)."
echo "matrix=$(printf '%s' "$subset" | jq -c .)" >> "$GITHUB_OUTPUT"
fi
# --------------------------------------------------------------------
# Unit tests across the matrix `setup` emitted. A consistent 96% coverage
# gate applies on ALL THREE OSes (#929): the floor lives in pyproject
# addopts (--cov-fail-under=96). Linux + macOS (both POSIX) hold it
# directly; Windows measures through `.coveragerc-win`, which omits the
# POSIX/ConPTY surface it physically can't run so the Windows-reachable
# code holds the same 96% (see the pytest step below). 96 (not higher) so
# the known coverage.py TRACER NOISE on Python 3.11/3.14 (~1% of executed
# lines dropped vs 3.12/3.13 — a coverage.py artifact, not real gaps) stays
# comfortably within margin on every cell. Skipped wholesale on a docs-only PR.
# --------------------------------------------------------------------
tests:
name: tests (${{ matrix.os }}, ${{ matrix.python }})
needs: setup
if: needs.setup.outputs.code == 'true' && github.event.pull_request.draft != true
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
# Concurrency cap as a simple boolean toggle via the CI_CAP_PARALLEL repo
# variable: set it to `false` to LIFT the cap (256 = GitHub's matrix-job
# ceiling, i.e. effectively uncapped on the 12-cell grid); anything else —
# `true`, unset, or garbage — keeps the safe default cap of 4 so a clauster
# run doesn't monopolise the account-wide runner pool. `4`/`256` are UNQUOTED
# numeric literals, so the expression evaluates to a NUMBER (max-parallel
# rejects a string — a quoted value is what broke the first attempt); the
# variable is only ever string-COMPARED (case-insensitive), never parsed as a
# number, so no value can error or break matrix expansion. The 4-cell subset
# PRs are unaffected by a cap of 4 — only the full grid (push:main / nightly)
# is throttled.
max-parallel: ${{ vars.CI_CAP_PARALLEL != 'false' && 4 || 256 }}
matrix: ${{ fromJSON(needs.setup.outputs.matrix) }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up uv + Python
uses: $/.github/actions/setup
with:
python-version: ${{ matrix.python }}
# `shell: bash` keeps one script across all three runners (Git Bash ships
# on Windows). Matrix values flow through env (not inlined) for zizmor.
- name: pytest
shell: bash
env:
PYTHON_VERSION: ${{ matrix.python }}
RUNNER_OS: ${{ runner.os }}
MATRIX_OS: ${{ matrix.os }}
run: |
# Time the suite and record it — the runners (esp. Windows) vary a lot, so a
# per-cell wall time on the run Summary + the slowest tests (`--durations`) make
# a REAL slowdown obvious instead of mined from job timestamps. `|| rc=$?` so the
# timing is still written when pytest fails (the default shell is `bash -eo pipefail`).
start=$(date +%s)
rc=0
# Consistent 96% coverage floor on ALL THREE OSes (#929) — the gate lives in
# pyproject addopts (--cov-fail-under=96). macOS is POSIX, so it runs the same
# code as Linux and holds the floor directly. Windows physically can't run
# clauster's POSIX code (fcntl/os.openpty/termios/dir-fsync) and its ConPTY
# keeper is exercised via a fake process seam on Linux, so Windows measures
# through `.coveragerc-win`, which excludes that un-runnable surface (omit
# pty_keeper + `# pragma: skip-on-win`); the Windows-REACHABLE code then holds
# the same 96%. Every OS still emits coverage.xml + junit.xml for its Codecov flag.
if [ "$RUNNER_OS" = "Windows" ]; then
uv run --python "$PYTHON_VERSION" pytest -q --durations=15 \
--cov-config=.coveragerc-win \
--cov-report=xml --junitxml=junit.xml -o junit_family=legacy || rc=$?
else
uv run --python "$PYTHON_VERSION" pytest -q --durations=15 \
--cov-report=xml --junitxml=junit.xml -o junit_family=legacy || rc=$?
fi
dur=$(( $(date +%s) - start ))
{
echo "### ⏱ pytest timing"
echo ""
echo "| OS | Python | Wall time | Result |"
echo "| --- | --- | --- | --- |"
echo "| $MATRIX_OS | $PYTHON_VERSION | ${dur}s | $([ "$rc" -eq 0 ] && echo pass || echo FAIL) |"
} >> "$GITHUB_STEP_SUMMARY"
exit "$rc"
# Upload coverage from EVERY cell, tagged with its OS as a Codecov flag
# (Linux/macOS/Windows), so coverage is broken out per platform — the axis
# where clauster actually varies (74 sys.platform branches; zero version
# branches). The internal-PR subset spans all three OSes, so every code PR
# uploads all three flags on the current commit and no carryforward is needed.
# Token-based upload (no id-token perm). Non-blocking: Codecov isn't a
# required check and upload flakiness must never fail CI. `!cancelled()` so
# a FAILED pytest still uploads (pytest writes the reports before the gate),
# surfacing a coverage drop precisely when it matters. Skips the release
# bot's PRs (knope opens them as the clauster-ci App; code never moves, so the
# status/comment is pure noise) — filtered on the PR author; the login is empty
# on push, so main-branch uploads are unaffected.
- name: Upload coverage to Codecov
if: ${{ !cancelled() && github.event.pull_request.user.login != 'clauster-ci[bot]' }}
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: coverage.xml
flags: ${{ runner.os }}
fail_ci_if_error: false
# Upload test results once per OS — the always-present gate cell for each
# (Linux 3.13 / macOS 3.12 / Windows 3.14, in both the subset and the full
# matrix) — tagged with its OS as a Codecov flag, mirroring the coverage
# upload, so Test Analytics (flaky/slow tests) breaks out per platform. One
# cell per OS (not every matrix cell) keeps the duration metrics from
# double-counting Python versions. Every cell now writes junit.xml (pytest step).
- name: Upload test results to Codecov
if: >-
${{ !cancelled() && github.event.pull_request.user.login != 'clauster-ci[bot]'
&& ((runner.os == 'Linux' && matrix.python == '3.13')
|| (runner.os == 'macOS' && matrix.python == '3.12')
|| (runner.os == 'Windows' && matrix.python == '3.14')) }}
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: junit.xml
flags: ${{ runner.os }}
report_type: test_results
fail_ci_if_error: false
# --------------------------------------------------------------------
# Build sdist + wheel. Cheap; confirms the package is shippable.
# --------------------------------------------------------------------
build:
name: build sdist + wheel
needs: setup
if: needs.setup.outputs.code == 'true' && github.event.pull_request.draft != true
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up uv + Python
uses: $/.github/actions/setup
with:
python-version: "3.13"
extras: "" # build only needs the build backend, not test/lint tooling
- name: Build
run: uv build
- name: Upload dist artefacts
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dist
path: dist/
retention-days: 7
if-no-files-found: error
# --------------------------------------------------------------------
# Docker smoke-test. The image is built + Trivy-scanned in security.yml
# but never RUN there — this is the runtime regression net. Build once,
# then prove the server actually boots and serves /healthz two ways:
# 1. `docker run` — the image itself.
# 2. `docker compose` — the published compose.yaml (#97).
# The image binds 0.0.0.0, so it requires enforced auth to start; we mint
# a throwaway argon2id hash from the image's own auth module (no real
# secret) — /healthz is public, so liveness needs no login. Docs-only PRs
# skip it.
# --------------------------------------------------------------------
docker:
name: docker smoke-test
needs: setup
if: needs.setup.outputs.code == 'true' && github.event.pull_request.draft != true
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Set up Buildx
uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
# Build amd64 once, load locally, and tag it as the compose image name too
# so `docker compose` uses this build (pull policy "missing" → local hit),
# never the published :latest. The gha cache keeps cold builds cheap.
- name: Build image
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
load: true
platforms: linux/amd64
tags: |
clauster:smoke
ghcr.io/schubydoo/clauster:latest
cache-from: type=gha
cache-to: type=gha,mode=max
# Throwaway hash from the image's OWN auth code — non-interactive (the CLI's
# hash-password uses getpass), no hard-coded credential to scan-flag.
- name: Mint a throwaway auth hash
id: hash
run: |
set -euo pipefail
HASH=$(docker run --rm --entrypoint python clauster:smoke -c \
"from clauster.auth import hash_password, make_hasher; print(hash_password(make_hasher(), 'ci-smoke'))")
echo "::add-mask::$HASH"
echo "hash=$HASH" >> "$GITHUB_OUTPUT"
# Boot prerequisites the image deliberately omits:
# • clauster.yml — clauster needs one to exist (env vars only override
# scalars); projects_root=/projects exists in the image.
# • a `claude` binary — clauster resolves it on PATH at startup and exits
# if missing. The image ships WITHOUT claude by design (users mount it —
# see compose.yaml), so we mount a tiny stub, exactly as documented. The
# compose override mirrors compose.yaml's commented-out claude mount.
- name: Write smoke config + claude stub
run: |
set -euo pipefail
mkdir -p config projects
cat > config/clauster.yml <<'YML'
schema_version: 1
projects_root: /projects
state_dir: /config/state
log_format: json
YML
cat > claude-stub <<'SH'
#!/bin/sh
case "$1" in
--version) echo "2.1.200 (Claude Code)" ;;
agents) echo "[]" ;;
*) echo "[]" ;;
esac
SH
chmod +x claude-stub
cat > compose.ci.yaml <<'YML'
# CI-only override: mount the stub claude onto PATH (compose.yaml ships
# this commented out). Not committed — written by the smoke-test job.
services:
clauster:
image: clauster:smoke
volumes:
- ./claude-stub:/usr/local/bin/claude:ro
YML
- name: docker run → /healthz
env:
HASH: ${{ steps.hash.outputs.hash }}
run: |
set -euo pipefail
docker run -d --name clauster-smoke -p 7621:7621 \
-e CLAUSTER_AUTH_ENABLED=true \
-e CLAUSTER_AUTH_PASSWORD_REQUIRED=true \
-e CLAUSTER_AUTH_PASSWORD_HASH="$HASH" \
-v "$PWD/config:/config" \
-v "$PWD/projects:/projects" \
-v "$PWD/claude-stub:/usr/local/bin/claude:ro" \
clauster:smoke
ok=false
for _ in $(seq 1 30); do
code=$(curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:7621/healthz || true)
if [ "$code" = "200" ]; then ok=true; break; fi
sleep 2
done
if [ "$ok" != "true" ]; then
echo "::error::docker run: /healthz never returned 200"
docker logs clauster-smoke || true
exit 1
fi
echo "docker run: /healthz 200 ✓"
docker rm -f clauster-smoke
- name: docker compose → /healthz
env:
# compose.yaml requires this (its `:?` guard); a real env var holds the
# literal hash fine — only .env files need the `$`→`$$` doubling.
CLAUSTER_AUTH_PASSWORD_HASH: ${{ steps.hash.outputs.hash }}
run: |
set -euo pipefail
docker compose -f compose.yaml -f compose.ci.yaml up -d
ok=false
for _ in $(seq 1 30); do
code=$(curl -fsS -o /dev/null -w '%{http_code}' http://127.0.0.1:7621/healthz || true)
if [ "$code" = "200" ]; then ok=true; break; fi
sleep 2
done
if [ "$ok" != "true" ]; then
echo "::error::docker compose: /healthz never returned 200"
docker compose -f compose.yaml -f compose.ci.yaml logs || true
exit 1
fi
echo "docker compose: /healthz 200 ✓"
- name: Tear down
if: always()
run: |
docker compose -f compose.yaml -f compose.ci.yaml down -v || true
# Also reap the docker-run container — a failed healthz poll exits
# before its inline `docker rm`, leaving it bound to port 7621.
docker rm -f clauster-smoke || true
# --------------------------------------------------------------------
# musl portability canary. The `tests` matrix is all glibc/native; the
# published Docker image runs on Alpine/musl, so run the unit suite once on
# musl + CPython 3.14 (the image's exact libc + interpreter) to catch a
# musl-only regression before it ships. Driven via `docker run` from the ubuntu
# host rather than a `container:` job — GitHub injects a glibc node that can't
# execute on musl, so actions/checkout inside an Alpine container fails. No
# coverage gate (this is a portability canary, not the coverage cell); atheris
# has no musl wheel, so the dev extra is skipped and the fuzz smoke test
# importorskips (1 skip), exactly as it does elsewhere.
# --------------------------------------------------------------------
musl-tests:
name: musl smoke-test (alpine / py3.14)
needs: setup
if: needs.setup.outputs.code == 'true' && github.event.pull_request.draft != true
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: pytest on alpine musl
env:
# Kept byte-identical to the Dockerfile's `FROM alpine:` pin — the two
# must never diverge, or this canary validates a different Alpine than
# we ship. Hoisted out of the `run:` script into a step `env:` so the
# annotation below sits on a mapping key a Renovate custom manager can
# match cleanly (the manager itself lives in the shared renovate-config
# preset, keyed to the `ALPINE_MUSL_IMAGE` name + this annotation).
# renovate: datasource=docker depName=alpine versioning=docker
ALPINE_MUSL_IMAGE: "alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a"
run: |
set -euo pipefail
docker run --rm -v "$PWD":/src:ro \
"$ALPINE_MUSL_IMAGE" \
sh -euc '
apk add --no-cache uv python3 git >/dev/null
cp -a /src /work && cd /work
uv sync --frozen --no-dev --python python3
uv pip install --python .venv/bin/python \
pytest pytest-asyncio pytest-xdist pytest-timeout hypothesis pyte
.venv/bin/python -m pytest --ignore=tests/e2e -o addopts="" \
-n auto --timeout=120 --timeout-method=thread -q -p no:cacheprovider
'
# --------------------------------------------------------------------
# Required-checks aggregator. ONE check for the `main` ruleset to gate
# on (context: "ci required checks passed"), regardless of how many
# underlying jobs exist. `if: always()` so it runs even when a need
# failed or was skipped; jq fails it iff any listed job is neither
# success nor skipped. Add a ci.yml job by appending to `needs:`.
# --------------------------------------------------------------------
ci-required-checks-passed:
name: ci required checks passed
# `always()` so a failed/skipped need still lets this job evaluate them; the
# draft term skips the whole aggregator on a draft PR, matching its upstreams
# (see the DRAFT PRs note above the `setup` job).
if: always() && github.event.pull_request.draft != true
needs:
- setup
- tests
- build
- docker
- musl-tests
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Check required jobs passed
env:
NEEDS_JSON: ${{ toJSON(needs) }}
run: |
failing=$(echo "$NEEDS_JSON" | jq -r '
to_entries[]
| select(.value.result != "success" and .value.result != "skipped")
| "\(.key): \(.value.result)"
')
if [ -n "$failing" ]; then
echo "Failed or cancelled jobs:"
echo "$failing"
exit 1
fi
echo "All required jobs passed (or were intentionally skipped)."