diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1505ffb9..5d153ba7 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,101 @@ 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. + # + # 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 + 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 + + # 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" + # 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. + printf -- '---\npatch\n---\n\nPin the backend a `pip install` user runs to %s.\n' \ + "$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" + 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 — left as it is." + exit 0 + fi + 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." )" 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.