From 7f25479aebd43ad4a7072710240a89443288297f Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 30 Oct 2026 08:31:33 -0700 Subject: [PATCH] devops(roll): roll on the shared bot actions, start a roll with needs-roll --- .../actions/setup-triage-runner/action.yml | 25 +- .github/workflows/roll.yml | 343 ++++++++++-------- AGENTS.md | 4 +- 3 files changed, 223 insertions(+), 149 deletions(-) diff --git a/.github/actions/setup-triage-runner/action.yml b/.github/actions/setup-triage-runner/action.yml index 0e1588634..ab7a21f02 100644 --- a/.github/actions/setup-triage-runner/action.yml +++ b/.github/actions/setup-triage-runner/action.yml @@ -1,10 +1,16 @@ name: 'Set up the triage runner' description: > - Prepares the environment that the triage and revision agents work in: the checkout built as CI - builds it, with the browsers and the Copilot CLI installed, and the upstream checkout in .upstream/ - that the driver is built from. Exports PLAYWRIGHT_DIR, the path of that checkout, and CI_ENV_EXEC, - the wrapper from microsoft/playwright that runs a command under xvfb on Linux and at normal I/O - priority on Windows. + Prepares the environment that the triage, revision and roll agents work in: the checkout built as + CI builds it, with the browsers and the Copilot CLI installed, and the upstream checkout in + .upstream/ that the driver is built from. Exports PLAYWRIGHT_DIR, the path of that checkout, and + CI_ENV_EXEC, the wrapper from microsoft/playwright that runs a command under xvfb on Linux and at + normal I/O priority on Windows. With pin, it does steps 1 and 2 of the playwright-roll skill before + the build: it pins that commit, updates NODE_VERSION and generates the API from the new pin. +inputs: + pin: + description: 'microsoft/playwright commit to roll to; empty builds the commit in DRIVER_VERSION' + required: false + default: '' runs: using: composite steps: @@ -22,6 +28,15 @@ runs: pip install -r local-requirements.txt pip install -r requirements.txt pip install -e . + - name: Pin and generate the API + if: inputs.pin != '' + shell: bash + env: + PIN: ${{ inputs.pin }} + run: | + python scripts/upstream.py pin "$PIN" + python scripts/update_node_version.py + ./scripts/update_api.sh - name: Check out upstream and build the wheel shell: bash run: | diff --git a/.github/workflows/roll.yml b/.github/workflows/roll.yml index 2b568e2a0..e1e2b99e6 100644 --- a/.github/workflows/roll.yml +++ b/.github/workflows/roll.yml @@ -1,12 +1,16 @@ -# Rolls Playwright to a new microsoft/playwright commit (the tip of main unless -# told otherwise) and opens a pull request. Copilot CLI ports the upstream changes -# with the playwright-roll skill. The pull request comes from the -# microsoft-playwright-automation app, so CI runs on it: this needs the -# PLAYWRIGHT_APP_CLIENT_ID variable and the PLAYWRIGHT_PRIVATE_KEY secret. +# Rolls Playwright to a new microsoft/playwright commit, the tip of main unless told otherwise. +# Copilot CLI ports the upstream changes with the playwright-roll skill, without write credentials. +# A fresh job opens the pull request using the microsoft-playwright-automation app, so CI runs on it; +# a partial roll opens as a draft. As in triage.yml, the steps that are the same for every Playwright +# repo are actions in microsoft/playwright, and .github/actions/setup-triage-runner prepares the +# runner: with pin, it also pins, generates and builds. # Upstream counterpart: microsoft/playwright.dev .github/workflows/roll-next.yml. name: Roll to latest Playwright on: + # Apply needs-roll to an issue to roll to the tip of main; re-apply it to trigger another roll. + issues: + types: [labeled] workflow_dispatch: inputs: ref: @@ -18,15 +22,34 @@ on: permissions: {} -concurrency: - group: ${{ github.workflow }} - cancel-in-progress: false - jobs: + start: + name: Post the run link on the issue + if: github.repository == 'microsoft/playwright-python' && github.event.label.name == 'needs-roll' + runs-on: ubuntu-latest + permissions: + issues: write + outputs: + comment: ${{ steps.post.outputs.id }} + env: + ISSUE: ${{ github.event.issue.number }} + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + WORKFLOW_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + steps: + - name: Post the run link + id: post + run: | + body=$(printf "Hi, I'm the Playwright bot. I started to roll Playwright to the tip of main: [agent run](%s)\n" "$WORKFLOW_URL") + id=$(gh api "repos/$GH_REPO/issues/$ISSUE/comments" -f body="$body" --jq .node_id) + echo "id=$id" >> "$GITHUB_OUTPUT" + versions: name: Find the commit + if: >- + github.repository == 'microsoft/playwright-python' && + (github.event_name == 'workflow_dispatch' || github.event.label.name == 'needs-roll') runs-on: ubuntu-24.04 - if: github.repository == 'microsoft/playwright-python' permissions: contents: read outputs: @@ -38,7 +61,7 @@ jobs: - name: Read the pinned and the new commit id: versions env: - REF: ${{ inputs.ref }} + REF: ${{ inputs.ref || 'main' }} run: | old_commit="$(cat DRIVER_VERSION)" new_commit="$(python3 scripts/upstream.py resolve "$REF")" @@ -50,19 +73,27 @@ jobs: } >> "$GITHUB_OUTPUT" roll: - name: Roll + name: Roll to ${{ needs.versions.outputs.new_short }} needs: versions if: needs.versions.outputs.new_commit != needs.versions.outputs.old_commit # The runner of CI: the skill runs ./test.sh, both suites on all its cores. runs-on: playwright-x64-ubuntu24-64-core timeout-minutes: 240 + # On the job and not on the workflow: every issue label starts this workflow, and a skipped + # run would replace a roll that waits in a group of the workflow. + concurrency: + group: roll + cancel-in-progress: false + defaults: + run: + shell: bash permissions: - contents: read copilot-requests: write outputs: + setup: ${{ steps.setup.outcome }} + version: ${{ steps.record.outputs.version }} + complete: ${{ steps.save.outputs.complete }} has_changes: ${{ steps.save.outputs.has_changes }} - version: ${{ steps.save.outputs.version }} - complete: ${{ steps.agent.outcome == 'success' && steps.check.outcome == 'success' }} env: OLD_COMMIT: ${{ needs.versions.outputs.old_commit }} NEW_COMMIT: ${{ needs.versions.outputs.new_commit }} @@ -71,67 +102,41 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # The runner context is not available in the env of the job. - - name: Set the output directory - run: echo "OUTPUT_DIR=$RUNNER_TEMP/roll" >> "$GITHUB_ENV" - - name: Set up Python - uses: actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7.1.0 - with: - python-version: "3.10" - - name: Set up Node.js - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 - with: - node-version: '24' - - name: Install dependencies + - name: Record the directories run: | - python -m pip install --upgrade pip - pip install -r local-requirements.txt - pip install -r requirements.txt - pip install -e . - npm install -g @github/copilot + echo "OUTPUT_DIR=$RUNNER_TEMP/roll" >> "$GITHUB_ENV" + echo "PATCH_DIR=$RUNNER_TEMP/roll-patch" >> "$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/roll" "$RUNNER_TEMP/roll-patch" # Steps 1 to 3 of the playwright-roll skill. - - name: Pin + - name: Set up the runner + id: setup + uses: ./.github/actions/setup-triage-runner + with: + pin: ${{ env.NEW_COMMIT }} + # Before the session, from .upstream/ at the new pin. + - name: Record the version and the upstream commits + id: record run: | - python scripts/upstream.py pin "$NEW_COMMIT" - python scripts/update_node_version.py - - name: Generate the API, the protocol files and the test registry - run: ./scripts/update_api.sh - - name: Build the driver and the wheel - run: python -m build --wheel - - name: Install browsers - run: python -m playwright install --with-deps chromium firefox webkit + echo "version=$(node -p "require('./.upstream/packages/playwright-core/package.json').version")" >> "$GITHUB_OUTPUT" + python scripts/upstream.py log "$OLD_COMMIT" > "$RUNNER_TEMP/commits.md" - name: Roll with Copilot CLI id: agent - # A partial roll still opens a pull request, as a draft. - continue-on-error: true - env: - COPILOT_GITHUB_TOKEN: ${{ github.token }} - MAX_AI_CREDITS: ${{ inputs.max_ai_credits }} - run: | - mkdir -p "$OUTPUT_DIR" - PROMPT=$(cat < "$OUTPUT_DIR/roll.patch" - if [[ -s "$OUTPUT_DIR/roll.patch" ]]; then + git diff --cached --binary "$GITHUB_SHA" > "$PATCH_DIR/roll.patch" + if [[ -s "$PATCH_DIR/roll.patch" ]]; then echo "has_changes=true" >> "$GITHUB_OUTPUT" else echo "has_changes=false" >> "$GITHUB_OUTPUT" fi - echo "version=$(node -p "require('./.upstream/packages/playwright-core/package.json').version")" >> "$GITHUB_OUTPUT" - python scripts/upstream.py log "$OLD_COMMIT" > "$OUTPUT_DIR/commits.md" + echo "complete=$COMPLETE" >> "$GITHUB_OUTPUT" + python - <<'EOF' + import json + import os + from pathlib import Path + + env = os.environ + def read(path): + return path.read_text(encoding="utf-8").strip() if path.is_file() else "" + + parts = [read(Path(env["OUTPUT_DIR"], "summary.md")) or "The agent wrote no description."] + if env["COMPLETE"] != "true": + parts.append(f"**The roll is not complete:** the agent or the checks failed. See the [roll run]({env['RUN_URL']}).") + # A description has at most 65536 characters: a long range keeps its first commits. + commits = [line for line in read(Path(env["RUNNER_TEMP"], "commits.md")).splitlines() if line] + total = len(commits) + while len("\n".join(commits)) > 50000: + commits = commits[: len(commits) * 9 // 10] + if len(commits) < total: + commits.append(f"- … and {total - len(commits)} more: {env['COMPARE_URL']}") + parts.append(f"
\nUpstream commits ({total})\n\n" + "\n".join(commits) + "\n\n
") + parts.append(f"Upstream changes: {env['COMPARE_URL']}") + if env.get("ISSUE_URL"): + parts.append(f"Fixes {env['ISSUE_URL']}") + Path(env["PATCH_DIR"], "pr-body.md").write_text("\n\n".join(parts) + "\n", encoding="utf-8") + Path(env["PATCH_DIR"], "roll.json").write_text(json.dumps({"title": env["TITLE"]}) + "\n", encoding="utf-8") + EOF { echo '```' git diff --cached --stat "$GITHUB_SHA" echo '```' } >> "$GITHUB_STEP_SUMMARY" - - name: Upload the roll - if: always() + - name: Upload the pull request uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: - name: roll - path: ${{ env.OUTPUT_DIR }} - if-no-files-found: warn + name: roll-patch + path: ${{ env.PATCH_DIR }} + if-no-files-found: error publish: name: Open the pull request needs: [versions, roll] - if: needs.roll.outputs.has_changes == 'true' - runs-on: ubuntu-24.04 - permissions: - contents: read - pull-requests: write - env: - TITLE: "chore: roll Playwright to ${{ needs.roll.outputs.version }} (${{ needs.versions.outputs.new_short }})" - BRANCH: roll/${{ needs.versions.outputs.new_short }} + if: ${{ !cancelled() && needs.roll.outputs.has_changes == 'true' }} + runs-on: ubuntu-latest + outputs: + pr: ${{ steps.publish.outputs.pull-request }} steps: - uses: actions/create-github-app-token@64ab729a6294000f5aa4a4bc0b73d1f32bc45510 # v3.3.0 id: app-token with: client-id: ${{ vars.PLAYWRIGHT_APP_CLIENT_ID }} private-key: ${{ secrets.PLAYWRIGHT_PRIVATE_KEY }} - # Pushes as the app, so CI runs on the pull request. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: token: ${{ steps.app-token.outputs.token }} - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: - name: roll + name: roll-patch path: ${{ runner.temp }}/roll - - name: Push the branch + # A roll changes only what a roll changes; publish-patch rejects only .github/. The checks are + # here and not in a script, because the roll could change the script. They apply the patch to + # a separate index, so the checkout stays clean for publish-patch. + - name: Check the changed files + env: + GIT_INDEX_FILE: ${{ runner.temp }}/roll.index run: | - git apply --index "$RUNNER_TEMP/roll/roll.patch" - # A roll changes only what a roll changes. The checks are here and not in a - # script, because the roll could change the script. --no-renames lists a - # renamed file under its old name too. + git read-tree HEAD + git apply --cached "$RUNNER_TEMP/roll/roll.patch" + # --no-renames lists a renamed file under its old name too. git diff --cached --name-only --no-renames > "$RUNNER_TEMP/changed.txt" if grep -E '\.ya?ml$' "$RUNNER_TEMP/changed.txt"; then echo "The roll changes YAML files." >&2 @@ -215,55 +250,77 @@ jobs: echo "The roll adds a symbolic link." >&2 exit 1 fi - git config user.name "microsoft-playwright-automation[bot]" - git config user.email "203992400+microsoft-playwright-automation[bot]@users.noreply.github.com" - git checkout -b "$BRANCH" - git commit -m "$TITLE" - git push --force origin "$BRANCH" - - name: Create the pull request - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + # Never execute code or scripts from the roll in the app job. + - name: Validate, push and open draft + id: publish + uses: microsoft/playwright/.github/actions/publish-patch@main + with: + token: ${{ steps.app-token.outputs.token }} + dir: ${{ runner.temp }}/roll + patch: roll.patch + metadata: roll.json + body-file: pr-body.md + branch: roll/${{ needs.versions.outputs.new_short }}/${{ github.run_id }}-${{ github.run_attempt }} + commit-body: "${{ github.event.issue.html_url && format('Fixes: {0}', github.event.issue.html_url) || '' }}" + pull-request: true + base: ${{ github.ref_name }} + workflow-name: roll workflow + # publish-patch opens a draft; a complete roll is ready for review. + - name: Mark the pull request as ready + if: needs.roll.outputs.complete == 'true' env: - BASE: ${{ github.ref_name }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GH_REPO: ${{ github.repository }} + PR: ${{ steps.publish.outputs.number }} + run: gh pr ready "$PR" + + comment: + name: Comment on the issue + needs: [start, versions, roll, publish] + # Runs after every run that the label started, including failed ones, and whatever publish did. + if: ${{ !cancelled() && needs.start.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - name: Post the result and hide the run link + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ISSUE: ${{ github.event.issue.number }} + START_COMMENT: ${{ needs.start.outputs.comment }} + VERSIONS_RESULT: ${{ needs.versions.result }} + OLD_COMMIT: ${{ needs.versions.outputs.old_commit }} + NEW_COMMIT: ${{ needs.versions.outputs.new_commit }} + ROLL_RESULT: ${{ needs.roll.result }} + SETUP_OUTCOME: ${{ needs.roll.outputs.setup }} + VERSION: ${{ needs.roll.outputs.version }} COMPLETE: ${{ needs.roll.outputs.complete }} - COMPARE_URL: https://github.com/microsoft/playwright/compare/${{ needs.versions.outputs.old_commit }}...${{ needs.versions.outputs.new_commit }} + PR_URL: ${{ needs.publish.outputs.pr }} + PUBLISH_RESULT: ${{ needs.publish.result }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - const fs = require('fs'); - const { BASE, BRANCH, TITLE, COMPLETE, COMPARE_URL, RUN_URL, RUNNER_TEMP } = process.env; - const read = name => { - const file = `${RUNNER_TEMP}/roll/${name}`; - return fs.existsSync(file) ? fs.readFileSync(file, 'utf8').trim() : ''; - }; - const parts = [read('pr-body.md') || 'The agent wrote no description.']; - if (COMPLETE !== 'true') - parts.push(`**The roll is not complete:** the agent or the checks failed. See the [roll run](${RUN_URL}).`); - // A description has at most 65536 characters: a long range keeps its first commits. - let commits = read('commits.md').split('\n').filter(Boolean); - const total = commits.length; - while (commits.join('\n').length > 50000) - commits = commits.slice(0, Math.floor(commits.length * 0.9)); - if (commits.length < total) - commits.push(`- … and ${total - commits.length} more: ${COMPARE_URL}`); - parts.push(`
\nUpstream commits (${total})\n\n${commits.join('\n')}\n\n
`); - parts.push(`Upstream changes: ${COMPARE_URL}`); - parts.push(`Rolled by the [roll workflow](${RUN_URL}).`); - const body = parts.join('\n\n'); - const { owner, repo } = context.repo; - const { data: open } = await github.rest.pulls.list({ owner, repo, head: `${owner}:${BRANCH}`, state: 'open' }); - if (open.length) { - await github.rest.pulls.update({ owner, repo, pull_number: open[0].number, body }); - core.info(`Updated ${open[0].html_url}`); - return; - } - const { data: pr } = await github.rest.pulls.create({ - owner, - repo, - head: BRANCH, - base: BASE, - title: TITLE, - body, - draft: COMPLETE !== 'true', - }); - core.info(`Opened ${pr.html_url}`); + run: | + commit="microsoft/playwright@${NEW_COMMIT:0:10}" + if [[ "$VERSIONS_RESULT" != "success" ]]; then + text="I couldn't roll Playwright: finding the commit to roll to failed." + elif [[ "$OLD_COMMIT" == "$NEW_COMMIT" ]]; then + text="Nothing to roll: \`DRIVER_VERSION\` is at the tip of main already, $commit." + elif [[ -n "$PR_URL" && "$COMPLETE" == "true" ]]; then + text="I rolled Playwright to $VERSION, $commit: $PR_URL" + elif [[ -n "$PR_URL" ]]; then + text="I rolled Playwright to $VERSION, $commit, but the roll is not complete: the agent or the checks failed. Draft PR: $PR_URL" + elif [[ "$PUBLISH_RESULT" == "failure" ]]; then + text="I rolled Playwright to $VERSION, $commit, but opening the pull request failed." + elif [[ "$SETUP_OUTCOME" == "failure" ]]; then + text="I couldn't roll Playwright to $commit: pinning, generating the API, building the driver or installing the browsers failed." + elif [[ "$ROLL_RESULT" == "success" ]]; then + text="I couldn't roll Playwright to $commit: the roll changed no file." + else + text="I couldn't roll Playwright to $commit: the roll job ended with result \`$ROLL_RESULT\`, for example after a timeout." + fi + printf "Hi, I'm the Playwright bot. %s\n\nRolled by the Playwright bot - [agent run](%s)\n\n" "$text" "$RUN_URL" > "$RUNNER_TEMP/comment.md" + gh issue comment "$ISSUE" --body-file "$RUNNER_TEMP/comment.md" + if [[ -n "$START_COMMENT" ]]; then + gh api graphql -f query='mutation($id: ID!) { minimizeComment(input: { subjectId: $id, classifier: OUTDATED }) { clientMutationId } }' -f id="$START_COMMENT" \ + || echo "::warning::Could not hide the comment with the run link" + fi diff --git a/AGENTS.md b/AGENTS.md index bd9fd32b4..394291f2b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -138,7 +138,9 @@ left). ## Rolls -Use the `playwright-roll` skill. +Use the `playwright-roll` skill. On CI, `.github/workflows/roll.yml` runs it and opens the +PR. Apply the `needs-roll` label to an issue to roll to the tip of `main`, or start the +workflow by hand with a branch, a tag or a commit. ## Commits and PRs