From 1f962440fc83a92586067dcf0cd6398c36f904e8 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 29 Sep 2026 13:19:03 +0530 Subject: [PATCH 1/3] chore(release): open the Python backend pin bump automatically --- .github/workflows/release.yml | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1505ffb9..f302f5e7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,8 @@ jobs: permissions: contents: write id-token: write + # The pin bump below opens a PR rather than pushing to main. + pull-requests: write steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -69,3 +71,61 @@ jobs: --title "$TAG" \ --generate-notes \ --verify-tag + + # `BACKEND_NPM_VERSION` is the backend a `pip install` user actually runs, + # and nothing derives it: the Python adapter is not in the pnpm workspace, + # so changesets never sees it. Left to a human it is remembered late — + # `check_backend_pin.py` then fails every Python CI run and blocks the + # PyPI publish until someone edits one line. + # + # Opened as a PR, never pushed to main: the new backend has to be ON npm + # for the check to pass, which is why this runs after Publish, and a pin + # is a claim about a published artifact that deserves a review. + # + # A PR raised with GITHUB_TOKEN gets no CI — GitHub suppresses workflow + # runs for events its own token raises, to stop recursion. Set + # PIN_BUMP_TOKEN (a PAT or App token) and the checks run on their own; + # without it the PR needs a nudge, which its body says. + - name: Open the Python backend pin bump + env: + GITHUB_TOKEN: ${{ secrets.PIN_BUMP_TOKEN || secrets.GITHUB_TOKEN }} + PIN_FILE: packages/selenium-devtools-py/src/selenium_devtools/constants.py + run: | + set -euo pipefail + BACKEND=$(node -p "require('./packages/backend/package.json').version") + PIN=$(sed -n 's/^BACKEND_NPM_VERSION = "\(.*\)"$/\1/p' "$PIN_FILE") + if [ -z "$PIN" ]; then + echo "::error::No BACKEND_NPM_VERSION found in $PIN_FILE" + exit 1 + fi + if [ "$BACKEND" = "$PIN" ]; then + echo "Backend unchanged at $PIN — nothing to bump." + exit 0 + fi + # This value reaches a `sed` expression and a branch name. It comes + # from our own package.json, so this is a belt rather than a fix for + # a known hole — but a stray `/` would silently corrupt the pin. + case "$BACKEND" in + ''|*[!0-9A-Za-z.+-]*) + echo "::error::Backend version is not plain semver: $BACKEND" + exit 1 + ;; + esac + + BRANCH="chore/pin-backend-$BACKEND" + git switch -c "$BRANCH" + sed -i "s/^BACKEND_NPM_VERSION = .*/BACKEND_NPM_VERSION = \"$BACKEND\"/" "$PIN_FILE" + # Required, not decorative: python.yml refuses a branch that changes + # `src/` and documents nothing, so a pin-only PR would fail its own CI. + printf -- '---\npatch\n---\n\nPin the backend a `pip install` user runs to %s.\n' \ + "$BACKEND" > "packages/selenium-devtools-py/changes/pin-backend-$BACKEND.md" + git commit -am "chore(python): pin the backend to $BACKEND" + git push origin "$BRANCH" + gh pr create --base main --head "$BRANCH" \ + --title "chore(python): pin the backend to $BACKEND" \ + --body "$(printf '%s\n' \ + "\`@wdio/devtools-backend@$BACKEND\` is published, so the version a \`pip install\` user runs can move up from $PIN." \ + "" \ + "Opened by the npm release. \`scripts/check_backend_pin.py\` is what decides whether this pin is serviceable — it asks the published tarball whether it carries every contract name the adapter needs, so let that check run before merging." \ + "" \ + "If this PR shows no checks, it was raised with \`GITHUB_TOKEN\`, which GitHub will not let start a workflow. Close and reopen it, or push an empty commit, to get CI." )" From d423091d4fbac6583cde7920bb6f8e8e92d9e305 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 29 Sep 2026 13:36:40 +0530 Subject: [PATCH 2/3] fix(release): commit the fragment, and survive a re-run --- .github/workflows/release.yml | 27 ++++++++++++++++--- CLAUDE.md | 2 +- CONTRIBUTING.md | 7 +++++ .../selenium-devtools-py/changes/README.md | 5 ++++ 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f302f5e7..bb92e683 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,7 +86,11 @@ jobs: # runs for events its own token raises, to stop recursion. Set # PIN_BUMP_TOKEN (a PAT or App token) and the checks run on their own; # without it the PR needs a nudge, which its body says. + # + # Only for a `latest` release: the pin is what a `pip install` user gets, + # and a `next` publish is a test version nobody should be moved onto. - name: Open the Python backend pin bump + if: inputs.distTag == 'latest' || inputs.distTag == '' env: GITHUB_TOKEN: ${{ secrets.PIN_BUMP_TOKEN || secrets.GITHUB_TOKEN }} PIN_FILE: packages/selenium-devtools-py/src/selenium_devtools/constants.py @@ -113,14 +117,29 @@ jobs: esac BRANCH="chore/pin-backend-$BACKEND" - git switch -c "$BRANCH" + FRAGMENT="packages/selenium-devtools-py/changes/pin-backend-$BACKEND.md" + # -C, not -c: a re-run of a release that already reached this step + # (publish succeeded, something after it did not) finds its own branch + # already there, and `-c` would fail the step AFTER publication. + git switch -C "$BRANCH" sed -i "s/^BACKEND_NPM_VERSION = .*/BACKEND_NPM_VERSION = \"$BACKEND\"/" "$PIN_FILE" # Required, not decorative: python.yml refuses a branch that changes # `src/` and documents nothing, so a pin-only PR would fail its own CI. printf -- '---\npatch\n---\n\nPin the backend a `pip install` user runs to %s.\n' \ - "$BACKEND" > "packages/selenium-devtools-py/changes/pin-backend-$BACKEND.md" - git commit -am "chore(python): pin the backend to $BACKEND" - git push origin "$BRANCH" + "$BACKEND" > "$FRAGMENT" + # Named explicitly because the fragment is UNTRACKED: `commit -a` + # stages modified tracked files only, so it would ship the pin without + # the fragment that makes the pin mergeable. + git add "$PIN_FILE" "$FRAGMENT" + git commit -m "chore(python): pin the backend to $BACKEND" + # The branch is bot-owned and named for the version, so the only thing + # a force can overwrite is this same bump from an earlier failed run. + git push --force origin "$BRANCH" + + if [ "$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')" != "0" ]; then + echo "A pin PR for $BACKEND is already open — pushed to it instead." + exit 0 + fi gh pr create --base main --head "$BRANCH" \ --title "chore(python): pin the backend to $BACKEND" \ --body "$(printf '%s\n' \ diff --git a/CLAUDE.md b/CLAUDE.md index 9cb4b493..bb07ae20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -237,7 +237,7 @@ Two mechanisms, and the Python one exists because the npm one cannot reach it. C Neither is hand-versioned: both assemble the version and the changelog at release. The Python release additionally consumes its fragments, bumps `__version__` (the single source — `pyproject.toml` reads it via `dynamic = ["version"]`), and tags `py-v` **after** a successful publish, so the tag is an output pointing at the published tree rather than an input naming a version nothing has computed yet. -`BACKEND_NPM_VERSION` is the backend a `pip install` user actually runs, so the npm release goes first; `release.yml` opens the pin bump as a PR, and `scripts/check_backend_pin.py` refuses a PyPI publish whose pinned backend cannot serve the contract. +`BACKEND_NPM_VERSION` is the backend a `pip install` user actually runs, so the npm release goes first; `release.yml` opens the pin bump as a PR, and `scripts/check_backend_pin.py` refuses a PyPI publish whose pinned backend cannot serve the contract. That PR carries its own `changes/` fragment, because `python.yml` refuses a branch that changes `src/` and documents nothing — a pin-only PR would fail its own CI. It is a PR rather than a push because the pin is a claim about a *published* artifact and `check_backend_pin.py` is what adjudicates it; merging one queues a `patch` for the next PyPI release rather than bumping `__version__` there and then. Raised with `GITHUB_TOKEN` it arrives with **no checks at all** — GitHub suppresses workflow runs for events its own token raises — so either set `PIN_BUMP_TOKEN` or close/reopen the PR to get CI onto it. ### Commits diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 998d3ef2..d5cee62c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,13 @@ The frontmatter is the bump level alone (`patch`, `minor`, `major`); the body is python3 packages/selenium-devtools-py/scripts/changes.py next-version # what a release would publish ``` +**You don't bump `BACKEND_NPM_VERSION`.** That constant names the backend a +`pip install` user actually runs, so it can only move once that backend is on +npm — the npm release opens the bump as its own PR, fragment included. If a +Python CI run fails on *"Pinned backend serves this adapter's contract"*, the +answer is that the backend has not been released yet, not that your branch is +wrong. Raise it by hand only when you are also releasing the backend. + ## Before you push - `pnpm build`, `pnpm test`, and `pnpm lint` all green — don't push red. diff --git a/packages/selenium-devtools-py/changes/README.md b/packages/selenium-devtools-py/changes/README.md index f2adcae2..cc2efdc2 100644 --- a/packages/selenium-devtools-py/changes/README.md +++ b/packages/selenium-devtools-py/changes/README.md @@ -25,3 +25,8 @@ At release, `scripts/changes.py apply` takes the highest level of all pending files, bumps `__version__`, writes the section into `CHANGELOG.md`, and deletes the files it consumed. CI refuses a pull request that changes `src/` without adding one. + +One of these is written for you: the npm release opens a PR bumping +`BACKEND_NPM_VERSION` to the backend it just published, and that PR brings a +`patch` fragment with it — `constants.py` is under `src/`, so without one the +bump would fail the very check that exists to keep this package documented. From 5fe657f9f4fedc9fa55841b3d03cb4fa099affd6 Mon Sep 17 00:00:00 2001 From: Vishnu Vardhan Date: Tue, 29 Sep 2026 13:54:59 +0530 Subject: [PATCH 3/3] fix(release): never force the pin branch, and only move the pin forward --- .github/workflows/release.yml | 39 +++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bb92e683..5d153ba7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,12 +116,31 @@ jobs: ;; esac + # Only ever forward. A release that rolls the backend BACK — a revert + # on main, or a republish of an older `latest` — would otherwise open + # a PR lowering the pin onto a backend that may not serve the + # contract; `check_backend_pin.py` would reject it, after the noise. + NEWER=$(printf '%s\n%s\n' "$PIN" "$BACKEND" | sort -V | tail -1) + if [ "$NEWER" = "$PIN" ]; then + echo "Published backend $BACKEND is older than the pin $PIN — leaving it alone." + exit 0 + fi + BRANCH="chore/pin-backend-$BACKEND" FRAGMENT="packages/selenium-devtools-py/changes/pin-backend-$BACKEND.md" - # -C, not -c: a re-run of a release that already reached this step - # (publish succeeded, something after it did not) finds its own branch - # already there, and `-c` would fail the step AFTER publication. - git switch -C "$BRANCH" + # Continue the branch if it is already out there rather than recreating + # it: this step runs after `changeset publish`, so a re-run is normal, + # and by then a reviewer may have pushed to the open PR — regenerating + # `_contract.py` is the expected reason. A force push would erase that + # work, so there is none: we branch FROM the remote when it exists and + # fast-forward it. + git fetch --quiet origin "$BRANCH" 2>/dev/null || true + if git rev-parse --verify --quiet "origin/$BRANCH" >/dev/null; then + git switch -c "$BRANCH" "origin/$BRANCH" + else + git switch -c "$BRANCH" + fi + sed -i "s/^BACKEND_NPM_VERSION = .*/BACKEND_NPM_VERSION = \"$BACKEND\"/" "$PIN_FILE" # Required, not decorative: python.yml refuses a branch that changes # `src/` and documents nothing, so a pin-only PR would fail its own CI. @@ -131,13 +150,15 @@ jobs: # stages modified tracked files only, so it would ship the pin without # the fragment that makes the pin mergeable. git add "$PIN_FILE" "$FRAGMENT" - git commit -m "chore(python): pin the backend to $BACKEND" - # The branch is bot-owned and named for the version, so the only thing - # a force can overwrite is this same bump from an earlier failed run. - git push --force origin "$BRANCH" + if git diff --cached --quiet; then + echo "Branch $BRANCH already carries this bump — nothing to push." + else + git commit -m "chore(python): pin the backend to $BACKEND" + git push origin "$BRANCH" + fi if [ "$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')" != "0" ]; then - echo "A pin PR for $BACKEND is already open — pushed to it instead." + echo "A pin PR for $BACKEND is already open — left as it is." exit 0 fi gh pr create --base main --head "$BRANCH" \