From 88fc9451d0e8131856ac82e525cce746c80e4322 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 12:43:40 +0000 Subject: [PATCH 1/4] ci: add daily workflow to bump the project template's Playwright version What: the cookiecutter project template's Dockerfile pins a single Playwright version that selects the Apify base image tag and the in-image playwright pin. It was bumped by hand. This adds a scheduled workflow that keeps it current. How: a daily workflow (plus manual dispatch) runs a stdlib-only helper that reads the Apify Playwright base image's Docker Hub tags, picks the highest stable version, and rewrites only the pinned-version line when a newer one is available. On a change it commits to a fixed branch and opens (or refreshes) an auto-merge pull request, reusing the repo's existing signed-commit and gh pr automation. This mirrors the template docker-image bumping used in apify/actor-templates, adding the daily self-detection it does not need there. Alternatives considered: keying off PyPI's latest playwright or the resolved lockfile version instead of the base-image tags. Both were rejected because the template references the Apify base image, so a version is only safe to pin once that image is published; the base image can lag PyPI and the lockfile tracks the pip pin rather than the image. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YT79F2ENym57Q4YXh2wRBy --- .../workflows/update_playwright_version.yaml | 113 ++++++++++ scripts/update_playwright_version.py | 196 ++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 .github/workflows/update_playwright_version.yaml create mode 100644 scripts/update_playwright_version.py diff --git a/.github/workflows/update_playwright_version.yaml b/.github/workflows/update_playwright_version.yaml new file mode 100644 index 0000000000..02101fcbc3 --- /dev/null +++ b/.github/workflows/update_playwright_version.yaml @@ -0,0 +1,113 @@ +name: Update Playwright version + +on: + # Runs when manually triggered from the GitHub UI. + workflow_dispatch: + + # Runs every day at 04:00 UTC. + schedule: + - cron: '0 4 * * *' + +concurrency: + group: update-playwright-version + cancel-in-progress: false + +permissions: + contents: read + +env: + BRANCH_NAME: ci/update-playwright-version + +jobs: + update-playwright-version: + name: Update Playwright version + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v7 + with: + token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.13' + + # Detect the newest Playwright version that has a published Apify base image and, if it is + # newer than the one pinned in the template Dockerfile, rewrite that single line. The step + # exposes `changed`, `old`, and `new` outputs consumed by the steps below. + - name: Detect and apply new Playwright version + id: bump + run: python scripts/update_playwright_version.py --github-output "$GITHUB_OUTPUT" + + - name: Reset existing branch + if: steps.bump.outputs.changed == 'true' + env: + GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} + run: | + # If a PR already exists for this branch, disable auto-merge before we recreate the + # branch so the old commit can't be merged. + PR_NUMBER=$(gh pr list --head "$BRANCH_NAME" --base master --json number --jq '.[0].number // empty') + if [ -n "$PR_NUMBER" ]; then + gh pr merge "$PR_NUMBER" --disable-auto || true + fi + + # Delete the remote branch if it exists. The signed-commit step below recreates it from + # the current HEAD (origin/master). + if git ls-remote --heads origin "$BRANCH_NAME" | grep -q "$BRANCH_NAME"; then + echo "Deleting existing remote branch $BRANCH_NAME" + git push origin --delete "$BRANCH_NAME" + fi + + - name: Commit and push changes + id: commit-and-push + if: steps.bump.outputs.changed == 'true' + uses: apify/actions/signed-commit@v1.3.0 + with: + message: 'chore: Update Playwright version in project template to ${{ steps.bump.outputs.new }}' + github-token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} + branch: ${{ env.BRANCH_NAME }} + create-branch: 'true' + + - name: Create or update Pull Request + if: steps.commit-and-push.outputs.committed == 'true' + env: + GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} + run: | + PR_BODY="Automated bump of the Playwright version pinned by the project template Dockerfile. + + - Previous version: \`${{ steps.bump.outputs.old }}\` + - New version: \`${{ steps.bump.outputs.new }}\` + + > Generated by the [Update Playwright version](https://github.com/apify/crawlee-python/actions/workflows/update_playwright_version.yaml) workflow." + + # Check if a PR already exists for this branch. + PR_NUMBER=$(gh pr list --head "$BRANCH_NAME" --base master --json number --jq '.[0].number // empty') + + if [ -z "$PR_NUMBER" ]; then + echo "Creating new PR" + gh pr create \ + --title "chore: Update Playwright version in project template to ${{ steps.bump.outputs.new }}" \ + --body "$PR_BODY" \ + --base master \ + --head "$BRANCH_NAME" + + PR_NUMBER=$(gh pr list --head "$BRANCH_NAME" --base master --json number --jq '.[0].number // empty') + else + echo "PR #$PR_NUMBER already exists for branch $BRANCH_NAME, updating it" + gh pr edit "$PR_NUMBER" --body "$PR_BODY" + fi + + # Validate that we have a PR number before proceeding. + if [ -z "$PR_NUMBER" ]; then + echo "::error::Failed to determine pull request number for branch $BRANCH_NAME" + exit 1 + fi + + # Enable auto-merge so the PR merges automatically once CI passes. If CI fails, the PR + # stays open for manual review. + echo "Enabling auto-merge for PR #$PR_NUMBER" + gh pr merge "$PR_NUMBER" --auto --squash --delete-branch \ + || echo "::warning::Failed to enable auto-merge. The PR remains open for manual review." diff --git a/scripts/update_playwright_version.py b/scripts/update_playwright_version.py new file mode 100644 index 0000000000..f2ae6a75ed --- /dev/null +++ b/scripts/update_playwright_version.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Bump the Playwright version pinned by the cookiecutter project template's Dockerfile. + +The template Dockerfile pins a single Playwright version (a Jinja ``# % set +playwright_version = '...'`` line). That version selects the Apify base image tag +(``apify/actor-python-playwright*:-``) and the in-image +``playwright==`` pin, so the version is only safe to bump once Apify has +published the matching base image. This script therefore uses the Apify Playwright base image's +Docker Hub tags as the source of truth: it picks the highest stable ``-`` tag, +compares it to the version currently in the Dockerfile, and rewrites that single line if a newer +one is available. + +Intended to run from the repository root inside the daily ``update_playwright_version`` workflow, +but it is dependency-free (standard library only) and can be run locally. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import urllib.request +from pathlib import Path +from urllib.error import URLError + +# Docker Hub repository whose tags gate which Playwright versions are safe to pin. The +# playwright/chrome/firefox/webkit/camoufox variants share the same ``-`` tag, +# so the primary repo is a sufficient reference for the shared version. +DOCKER_REPO = 'apify/actor-python-playwright' +DOCKER_HUB_TAGS_URL = f'https://hub.docker.com/v2/repositories/{DOCKER_REPO}/tags' + +# Path (relative to the repository root) of the template Dockerfile that holds the pinned version. +DEFAULT_DOCKERFILE = Path('src/crawlee/project_template/{{cookiecutter.project_name}}/Dockerfile') + +# The pinned version line, e.g. ``# % set playwright_version = '1.60.0'``. +VERSION_LINE_RE = re.compile(r"""(?P# % set playwright_version = ')(?P[^']+)(?P')""") + +# The Python part of the base image tag, e.g. the ``3.13`` in ``...:3.13-1.60.0``. +PYTHON_PREFIX_RE = re.compile(r'python-playwright[a-z-]*:(?P\d+\.\d+)-') + +# A stable release version: exactly ``MAJOR.MINOR.PATCH`` with no pre-release/date suffix. +STABLE_VERSION_RE = re.compile(r'^(\d+)\.(\d+)\.(\d+)$') + +REPO_ROOT = Path(__file__).resolve().parent.parent + + +def parse_version(version: str) -> tuple[int, int, int] | None: + """Parse a stable ``MAJOR.MINOR.PATCH`` string into a comparable tuple, or None if not stable.""" + match = STABLE_VERSION_RE.match(version) + if match is None: + return None + return (int(match.group(1)), int(match.group(2)), int(match.group(3))) + + +def read_current_version(dockerfile: Path) -> str: + """Return the Playwright version currently pinned in the Dockerfile template.""" + content = dockerfile.read_text(encoding='utf-8') + match = VERSION_LINE_RE.search(content) + if match is None: + raise ValueError(f'Could not find a `# % set playwright_version = ...` line in {dockerfile}') + return match.group('version') + + +def read_python_prefix(dockerfile: Path, default: str = '3.13') -> str: + """Return the Python version prefix used in the base image tag (e.g. ``3.13``).""" + content = dockerfile.read_text(encoding='utf-8') + match = PYTHON_PREFIX_RE.search(content) + if match is None: + return default + return match.group('python') + + +def latest_stable_version(tags: list[str], python_prefix: str) -> str | None: + """Pick the highest stable Playwright version among ``-`` tags.""" + wanted = re.compile(rf'^{re.escape(python_prefix)}-(?P\d+\.\d+\.\d+)$') + best: tuple[int, int, int] | None = None + best_str: str | None = None + for tag in tags: + match = wanted.match(tag) + if match is None: + continue + version = match.group('version') + parsed = parse_version(version) + if parsed is None: + continue + if best is None or parsed > best: + best, best_str = parsed, version + return best_str + + +def fetch_tags(url: str = DOCKER_HUB_TAGS_URL, *, page_size: int = 100, max_pages: int = 50) -> list[str]: + """Fetch all tag names for the Docker Hub repository, following pagination.""" + tags: list[str] = [] + next_url: str | None = f'{url}?page_size={page_size}' + pages = 0 + while next_url and pages < max_pages: + request = urllib.request.Request(next_url, headers={'User-Agent': 'crawlee-python-version-bumper'}) # noqa: S310 + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 + payload = json.loads(response.read().decode('utf-8')) + tags.extend(result['name'] for result in payload.get('results', [])) + next_url = payload.get('next') + pages += 1 + return tags + + +def bump_dockerfile(dockerfile: Path, new_version: str) -> bool: + """Rewrite the pinned Playwright version line in place. Returns True if the file changed.""" + content = dockerfile.read_text(encoding='utf-8') + new_content, count = VERSION_LINE_RE.subn( + lambda match: f'{match.group("prefix")}{new_version}{match.group("suffix")}', + content, + ) + if count == 0: + raise ValueError(f'Could not find a `# % set playwright_version = ...` line in {dockerfile}') + if new_content == content: + return False + dockerfile.write_text(new_content, encoding='utf-8') + return True + + +def write_github_output(path: Path, values: dict[str, str]) -> None: + """Append ``key=value`` lines to the file referenced by ``$GITHUB_OUTPUT``.""" + with path.open('a', encoding='utf-8') as output: + for key, value in values.items(): + output.write(f'{key}={value}\n') + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + '--dockerfile', + type=Path, + default=REPO_ROOT / DEFAULT_DOCKERFILE, + help='Path to the template Dockerfile that pins the Playwright version.', + ) + parser.add_argument( + '--github-output', + type=Path, + default=None, + help='Path to the GitHub Actions $GITHUB_OUTPUT file; when set, changed/old/new are written to it.', + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Detect and report a newer version without editing the Dockerfile.', + ) + args = parser.parse_args(argv) + + dockerfile: Path = args.dockerfile + if not dockerfile.is_file(): + print(f'error: Dockerfile not found: {dockerfile}', file=sys.stderr) + return 1 + + current = read_current_version(dockerfile) + python_prefix = read_python_prefix(dockerfile) + print(f'Current pinned Playwright version: {current} (base image Python prefix: {python_prefix})') + + try: + tags = fetch_tags() + except (URLError, TimeoutError, OSError, ValueError) as exc: + print(f'error: failed to fetch tags from Docker Hub: {exc}', file=sys.stderr) + return 1 + + latest = latest_stable_version(tags, python_prefix) + if latest is None: + print(f'error: no stable `{python_prefix}-` tags found for {DOCKER_REPO}', file=sys.stderr) + return 1 + print(f'Latest available Playwright base image version: {latest}') + + current_parsed = parse_version(current) + latest_parsed = parse_version(latest) + changed = False + if current_parsed is None: + print(f'warning: current version {current!r} is not a stable release; leaving it unchanged.') + elif latest_parsed is not None and latest_parsed > current_parsed: + if args.dry_run: + print(f'Would bump Playwright version: {current} -> {latest} (dry run)') + changed = True + else: + changed = bump_dockerfile(dockerfile, latest) + print(f'Bumped Playwright version: {current} -> {latest}') + else: + print('Playwright version is already up to date.') + + if args.github_output is not None: + write_github_output( + args.github_output, + {'changed': 'true' if changed else 'false', 'old': current, 'new': latest}, + ) + + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) From e8aff575603df26dd7fad808f07f2ff49f0a1e8c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 12:56:45 +0000 Subject: [PATCH 2/4] ci: simplify the Playwright version bump script and workflow Drop the argument parser and make the updater a single-purpose script that runs with no arguments and hardcodes the one Dockerfile it maintains. The workflow now detects whether anything changed with a plain git diff instead of consuming step outputs, removing the machinery that carried version values between steps. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YT79F2ENym57Q4YXh2wRBy --- .../workflows/update_playwright_version.yaml | 37 ++-- scripts/update_playwright_version.py | 205 +++--------------- 2 files changed, 51 insertions(+), 191 deletions(-) diff --git a/.github/workflows/update_playwright_version.yaml b/.github/workflows/update_playwright_version.yaml index 02101fcbc3..d7d429fd96 100644 --- a/.github/workflows/update_playwright_version.yaml +++ b/.github/workflows/update_playwright_version.yaml @@ -35,15 +35,20 @@ jobs: with: python-version: '3.13' - # Detect the newest Playwright version that has a published Apify base image and, if it is - # newer than the one pinned in the template Dockerfile, rewrite that single line. The step - # exposes `changed`, `old`, and `new` outputs consumed by the steps below. - - name: Detect and apply new Playwright version - id: bump - run: python scripts/update_playwright_version.py --github-output "$GITHUB_OUTPUT" + - name: Update Playwright version + run: python scripts/update_playwright_version.py + + - name: Detect changes + id: changes + run: | + if git diff --quiet; then + echo "has-changes=false" >> "$GITHUB_OUTPUT" + else + echo "has-changes=true" >> "$GITHUB_OUTPUT" + fi - name: Reset existing branch - if: steps.bump.outputs.changed == 'true' + if: steps.changes.outputs.has-changes == 'true' env: GH_TOKEN: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} run: | @@ -63,10 +68,10 @@ jobs: - name: Commit and push changes id: commit-and-push - if: steps.bump.outputs.changed == 'true' + if: steps.changes.outputs.has-changes == 'true' uses: apify/actions/signed-commit@v1.3.0 with: - message: 'chore: Update Playwright version in project template to ${{ steps.bump.outputs.new }}' + message: 'chore: update Playwright version in project template' github-token: ${{ secrets.APIFY_SERVICE_ACCOUNT_GITHUB_TOKEN }} branch: ${{ env.BRANCH_NAME }} create-branch: 'true' @@ -78,34 +83,22 @@ jobs: run: | PR_BODY="Automated bump of the Playwright version pinned by the project template Dockerfile. - - Previous version: \`${{ steps.bump.outputs.old }}\` - - New version: \`${{ steps.bump.outputs.new }}\` - > Generated by the [Update Playwright version](https://github.com/apify/crawlee-python/actions/workflows/update_playwright_version.yaml) workflow." - # Check if a PR already exists for this branch. PR_NUMBER=$(gh pr list --head "$BRANCH_NAME" --base master --json number --jq '.[0].number // empty') - if [ -z "$PR_NUMBER" ]; then echo "Creating new PR" gh pr create \ - --title "chore: Update Playwright version in project template to ${{ steps.bump.outputs.new }}" \ + --title "chore: update Playwright version in project template" \ --body "$PR_BODY" \ --base master \ --head "$BRANCH_NAME" - PR_NUMBER=$(gh pr list --head "$BRANCH_NAME" --base master --json number --jq '.[0].number // empty') else echo "PR #$PR_NUMBER already exists for branch $BRANCH_NAME, updating it" gh pr edit "$PR_NUMBER" --body "$PR_BODY" fi - # Validate that we have a PR number before proceeding. - if [ -z "$PR_NUMBER" ]; then - echo "::error::Failed to determine pull request number for branch $BRANCH_NAME" - exit 1 - fi - # Enable auto-merge so the PR merges automatically once CI passes. If CI fails, the PR # stays open for manual review. echo "Enabling auto-merge for PR #$PR_NUMBER" diff --git a/scripts/update_playwright_version.py b/scripts/update_playwright_version.py index f2ae6a75ed..3a8ef51d78 100644 --- a/scripts/update_playwright_version.py +++ b/scripts/update_playwright_version.py @@ -1,196 +1,63 @@ #!/usr/bin/env python3 -"""Bump the Playwright version pinned by the cookiecutter project template's Dockerfile. +"""Bump the Playwright version pinned by the project template's Dockerfile. -The template Dockerfile pins a single Playwright version (a Jinja ``# % set -playwright_version = '...'`` line). That version selects the Apify base image tag -(``apify/actor-python-playwright*:-``) and the in-image -``playwright==`` pin, so the version is only safe to bump once Apify has -published the matching base image. This script therefore uses the Apify Playwright base image's -Docker Hub tags as the source of truth: it picks the highest stable ``-`` tag, -compares it to the version currently in the Dockerfile, and rewrites that single line if a newer -one is available. +The template Dockerfile pins a single Playwright version (a Jinja ``# % set playwright_version += '...'`` line) that selects the Apify base image tag and the in-image ``playwright==`` +pin. A version is only safe to pin once Apify has published the matching base image, so the +Apify Playwright base image's Docker Hub tags are the source of truth: this picks the highest +stable ``-`` tag for the Python version the template already uses, and rewrites +the pinned version line if it is newer. The Python version itself is never changed. -Intended to run from the repository root inside the daily ``update_playwright_version`` workflow, -but it is dependency-free (standard library only) and can be run locally. +Single-purpose: run with no arguments from anywhere in the repository. """ from __future__ import annotations -import argparse import json import re -import sys import urllib.request from pathlib import Path -from urllib.error import URLError -# Docker Hub repository whose tags gate which Playwright versions are safe to pin. The -# playwright/chrome/firefox/webkit/camoufox variants share the same ``-`` tag, -# so the primary repo is a sufficient reference for the shared version. -DOCKER_REPO = 'apify/actor-python-playwright' -DOCKER_HUB_TAGS_URL = f'https://hub.docker.com/v2/repositories/{DOCKER_REPO}/tags' - -# Path (relative to the repository root) of the template Dockerfile that holds the pinned version. -DEFAULT_DOCKERFILE = Path('src/crawlee/project_template/{{cookiecutter.project_name}}/Dockerfile') +DOCKERFILE = Path(__file__).resolve().parent.parent / 'src/crawlee/project_template/{{cookiecutter.project_name}}/Dockerfile' +TAGS_URL = 'https://hub.docker.com/v2/repositories/apify/actor-python-playwright/tags?page_size=100' # The pinned version line, e.g. ``# % set playwright_version = '1.60.0'``. -VERSION_LINE_RE = re.compile(r"""(?P# % set playwright_version = ')(?P[^']+)(?P')""") - +VERSION_LINE = re.compile(r"(# % set playwright_version = ')([^']+)(')") # The Python part of the base image tag, e.g. the ``3.13`` in ``...:3.13-1.60.0``. -PYTHON_PREFIX_RE = re.compile(r'python-playwright[a-z-]*:(?P\d+\.\d+)-') - -# A stable release version: exactly ``MAJOR.MINOR.PATCH`` with no pre-release/date suffix. -STABLE_VERSION_RE = re.compile(r'^(\d+)\.(\d+)\.(\d+)$') - -REPO_ROOT = Path(__file__).resolve().parent.parent - - -def parse_version(version: str) -> tuple[int, int, int] | None: - """Parse a stable ``MAJOR.MINOR.PATCH`` string into a comparable tuple, or None if not stable.""" - match = STABLE_VERSION_RE.match(version) - if match is None: - return None - return (int(match.group(1)), int(match.group(2)), int(match.group(3))) - - -def read_current_version(dockerfile: Path) -> str: - """Return the Playwright version currently pinned in the Dockerfile template.""" - content = dockerfile.read_text(encoding='utf-8') - match = VERSION_LINE_RE.search(content) - if match is None: - raise ValueError(f'Could not find a `# % set playwright_version = ...` line in {dockerfile}') - return match.group('version') - - -def read_python_prefix(dockerfile: Path, default: str = '3.13') -> str: - """Return the Python version prefix used in the base image tag (e.g. ``3.13``).""" - content = dockerfile.read_text(encoding='utf-8') - match = PYTHON_PREFIX_RE.search(content) - if match is None: - return default - return match.group('python') - +PYTHON_PREFIX = re.compile(r'python-playwright[a-z-]*:(\d+\.\d+)-') -def latest_stable_version(tags: list[str], python_prefix: str) -> str | None: - """Pick the highest stable Playwright version among ``-`` tags.""" - wanted = re.compile(rf'^{re.escape(python_prefix)}-(?P\d+\.\d+\.\d+)$') - best: tuple[int, int, int] | None = None - best_str: str | None = None - for tag in tags: - match = wanted.match(tag) - if match is None: - continue - version = match.group('version') - parsed = parse_version(version) - if parsed is None: - continue - if best is None or parsed > best: - best, best_str = parsed, version - return best_str - -def fetch_tags(url: str = DOCKER_HUB_TAGS_URL, *, page_size: int = 100, max_pages: int = 50) -> list[str]: - """Fetch all tag names for the Docker Hub repository, following pagination.""" +def fetch_tags() -> list[str]: + """Return all tag names of the Apify Playwright base image, following pagination.""" tags: list[str] = [] - next_url: str | None = f'{url}?page_size={page_size}' - pages = 0 - while next_url and pages < max_pages: - request = urllib.request.Request(next_url, headers={'User-Agent': 'crawlee-python-version-bumper'}) # noqa: S310 - with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - payload = json.loads(response.read().decode('utf-8')) - tags.extend(result['name'] for result in payload.get('results', [])) - next_url = payload.get('next') - pages += 1 + url: str | None = TAGS_URL + while url: + with urllib.request.urlopen(url, timeout=30) as response: # noqa: S310 + payload = json.load(response) + tags.extend(result['name'] for result in payload['results']) + url = payload['next'] return tags -def bump_dockerfile(dockerfile: Path, new_version: str) -> bool: - """Rewrite the pinned Playwright version line in place. Returns True if the file changed.""" - content = dockerfile.read_text(encoding='utf-8') - new_content, count = VERSION_LINE_RE.subn( - lambda match: f'{match.group("prefix")}{new_version}{match.group("suffix")}', - content, - ) - if count == 0: - raise ValueError(f'Could not find a `# % set playwright_version = ...` line in {dockerfile}') - if new_content == content: - return False - dockerfile.write_text(new_content, encoding='utf-8') - return True - - -def write_github_output(path: Path, values: dict[str, str]) -> None: - """Append ``key=value`` lines to the file referenced by ``$GITHUB_OUTPUT``.""" - with path.open('a', encoding='utf-8') as output: - for key, value in values.items(): - output.write(f'{key}={value}\n') - - -def main(argv: list[str] | None = None) -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - '--dockerfile', - type=Path, - default=REPO_ROOT / DEFAULT_DOCKERFILE, - help='Path to the template Dockerfile that pins the Playwright version.', - ) - parser.add_argument( - '--github-output', - type=Path, - default=None, - help='Path to the GitHub Actions $GITHUB_OUTPUT file; when set, changed/old/new are written to it.', - ) - parser.add_argument( - '--dry-run', - action='store_true', - help='Detect and report a newer version without editing the Dockerfile.', - ) - args = parser.parse_args(argv) +def main() -> None: + content = DOCKERFILE.read_text(encoding='utf-8') + current = VERSION_LINE.search(content).group(2) + python_prefix = PYTHON_PREFIX.search(content).group(1) - dockerfile: Path = args.dockerfile - if not dockerfile.is_file(): - print(f'error: Dockerfile not found: {dockerfile}', file=sys.stderr) - return 1 + # Keep only stable `MAJOR.MINOR.PATCH` versions built for the template's current Python line. + tag_re = re.compile(rf'^{re.escape(python_prefix)}-(\d+\.\d+\.\d+)$') + versions = [tuple(int(p) for p in m.group(1).split('.')) for tag in fetch_tags() if (m := tag_re.match(tag))] + if not versions: + raise SystemExit(f'No stable {python_prefix}- base image tags found.') - current = read_current_version(dockerfile) - python_prefix = read_python_prefix(dockerfile) - print(f'Current pinned Playwright version: {current} (base image Python prefix: {python_prefix})') - - try: - tags = fetch_tags() - except (URLError, TimeoutError, OSError, ValueError) as exc: - print(f'error: failed to fetch tags from Docker Hub: {exc}', file=sys.stderr) - return 1 - - latest = latest_stable_version(tags, python_prefix) - if latest is None: - print(f'error: no stable `{python_prefix}-` tags found for {DOCKER_REPO}', file=sys.stderr) - return 1 - print(f'Latest available Playwright base image version: {latest}') - - current_parsed = parse_version(current) - latest_parsed = parse_version(latest) - changed = False - if current_parsed is None: - print(f'warning: current version {current!r} is not a stable release; leaving it unchanged.') - elif latest_parsed is not None and latest_parsed > current_parsed: - if args.dry_run: - print(f'Would bump Playwright version: {current} -> {latest} (dry run)') - changed = True - else: - changed = bump_dockerfile(dockerfile, latest) - print(f'Bumped Playwright version: {current} -> {latest}') + latest = max(versions) + latest_str = '.'.join(str(part) for part in latest) + if latest > tuple(int(part) for part in current.split('.')): + DOCKERFILE.write_text(VERSION_LINE.sub(rf'\g<1>{latest_str}\g<3>', content), encoding='utf-8') + print(f'Bumped Playwright version: {current} -> {latest_str}') else: - print('Playwright version is already up to date.') - - if args.github_output is not None: - write_github_output( - args.github_output, - {'changed': 'true' if changed else 'false', 'old': current, 'new': latest}, - ) - - return 0 + print(f'Playwright version is already up to date ({current}).') if __name__ == '__main__': - raise SystemExit(main()) + main() From d576920c61cff9dc5c443864989e14b35a519b0b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 13:04:04 +0000 Subject: [PATCH 3/4] ci: move the Playwright version updater into project_template Keep the updater next to the Dockerfile it maintains, under src/crawlee/project_template/, and resolve the Dockerfile path relative to the script instead of the repository root. The workflow invokes it from its new location. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01YT79F2ENym57Q4YXh2wRBy --- .github/workflows/update_playwright_version.yaml | 2 +- .../crawlee/project_template}/update_playwright_version.py | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) rename {scripts => src/crawlee/project_template}/update_playwright_version.py (90%) diff --git a/.github/workflows/update_playwright_version.yaml b/.github/workflows/update_playwright_version.yaml index d7d429fd96..76e1054d82 100644 --- a/.github/workflows/update_playwright_version.yaml +++ b/.github/workflows/update_playwright_version.yaml @@ -36,7 +36,7 @@ jobs: python-version: '3.13' - name: Update Playwright version - run: python scripts/update_playwright_version.py + run: python src/crawlee/project_template/update_playwright_version.py - name: Detect changes id: changes diff --git a/scripts/update_playwright_version.py b/src/crawlee/project_template/update_playwright_version.py similarity index 90% rename from scripts/update_playwright_version.py rename to src/crawlee/project_template/update_playwright_version.py index 3a8ef51d78..8b9a2ccfce 100644 --- a/scripts/update_playwright_version.py +++ b/src/crawlee/project_template/update_playwright_version.py @@ -8,7 +8,8 @@ stable ``-`` tag for the Python version the template already uses, and rewrites the pinned version line if it is newer. The Python version itself is never changed. -Single-purpose: run with no arguments from anywhere in the repository. +Single-purpose: run with no arguments. It lives next to the Dockerfile it maintains and +resolves that path relative to itself, so it can be run from anywhere in the repository. """ from __future__ import annotations @@ -18,7 +19,7 @@ import urllib.request from pathlib import Path -DOCKERFILE = Path(__file__).resolve().parent.parent / 'src/crawlee/project_template/{{cookiecutter.project_name}}/Dockerfile' +DOCKERFILE = Path(__file__).resolve().parent / '{{cookiecutter.project_name}}/Dockerfile' TAGS_URL = 'https://hub.docker.com/v2/repositories/apify/actor-python-playwright/tags?page_size=100' # The pinned version line, e.g. ``# % set playwright_version = '1.60.0'``. From 2c583f8369baa9e2a556b6d885ae45781240ce9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Wed, 1 Jul 2026 15:17:00 +0200 Subject: [PATCH 4/4] Review comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../project_template/update_playwright_version.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/crawlee/project_template/update_playwright_version.py b/src/crawlee/project_template/update_playwright_version.py index 8b9a2ccfce..58a304b033 100644 --- a/src/crawlee/project_template/update_playwright_version.py +++ b/src/crawlee/project_template/update_playwright_version.py @@ -42,9 +42,16 @@ def fetch_tags() -> list[str]: def main() -> None: content = DOCKERFILE.read_text(encoding='utf-8') - current = VERSION_LINE.search(content).group(2) - python_prefix = PYTHON_PREFIX.search(content).group(1) + version_match = VERSION_LINE.search(content) + if not version_match: + raise SystemExit(f'Pinned Playwright version line not found in {DOCKERFILE}.') + current = version_match.group(2) + + python_match = PYTHON_PREFIX.search(content) + if not python_match: + raise SystemExit(f'Python base image prefix not found in {DOCKERFILE}.') + python_prefix = python_match.group(1) # Keep only stable `MAJOR.MINOR.PATCH` versions built for the template's current Python line. tag_re = re.compile(rf'^{re.escape(python_prefix)}-(\d+\.\d+\.\d+)$') versions = [tuple(int(p) for p in m.group(1).split('.')) for tag in fetch_tags() if (m := tag_re.match(tag))]