From dedb8927cca331b4ff6ec5ded2f34c4d7cbfe609 Mon Sep 17 00:00:00 2001 From: Bill Huneke Date: Thu, 21 Sep 2023 23:28:28 -0400 Subject: [PATCH 1/8] test tracer: add 2 tests, coverage for plugin tracing --- testing/test_tracer.py | 83 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/testing/test_tracer.py b/testing/test_tracer.py index c90c78f1..13b29721 100644 --- a/testing/test_tracer.py +++ b/testing/test_tracer.py @@ -1,8 +1,15 @@ import pytest +from pluggy import HookimplMarker +from pluggy import HookspecMarker +from pluggy import PluginManager from pluggy._tracing import TagTracer +hookspec = HookspecMarker("example") +hookimpl = HookimplMarker("example") + + @pytest.fixture def rootlogger() -> TagTracer: return TagTracer() @@ -75,3 +82,79 @@ def test_setprocessor(rootlogger: TagTracer) -> None: log2("seen") tags, args = l2[0] assert args == ("seen",) + + +def test_plugin_tracing(pm: PluginManager) -> None: + class Api: + @hookspec + def hello(self, arg: object) -> None: + "api hook 1" + + pm.add_hookspecs(Api) + hook = pm.hook + test_hc = hook.hello + + class Plugin: + @hookimpl + def hello(self, arg): + return arg + 1 + + plugin = Plugin() + + trace_out: list[str] = [] + pm.trace.root.setwriter(trace_out.append) + pm.register(plugin) + pm.enable_tracing() + + out = test_hc(arg=3) + assert out == [4] + + assert trace_out == [ + " hello [hook]\n arg: 3\n", + " finish hello --> [4] [hook]\n", + ] + + +def test_dbl_plugin_tracing(pm: PluginManager) -> None: + class Api: + @hookspec + def hello(self, arg: object) -> None: + "api hook 1" + + pm.add_hookspecs(Api) + hook = pm.hook + test_hc = hook.hello + + class Plugin: + @hookimpl + def hello(self, arg): + return arg + 1 + + @hookimpl(specname="hello") + def hello_again(self, arg): + return arg + 100 + + plugin = Plugin() + + trace_out: list[str] = [] + pm.trace.root.setwriter(trace_out.append) + pm.register(plugin) + pm.enable_tracing() + + out = test_hc(arg=3) + assert out == [103, 4] + + assert trace_out == [ + " hello [hook]\n arg: 3\n", + " finish hello --> [103, 4] [hook]\n", + ] + + trace_out.clear() + pm.unregister(plugin) + out = test_hc(arg=3) + assert out == [] + + assert trace_out == [ + " hello [hook]\n arg: 3\n", + " finish hello --> [] [hook]\n", + ] From 17786e100a1406494050acc3c39fe55e1459b4f6 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 27 May 2026 12:40:09 +0200 Subject: [PATCH 2/8] feat: automate release pipeline with towncrier-fragments version scheme Replace the manual release process with an automated CI pipeline: - Configure setuptools_scm with the `towncrier-fragments` version scheme (from vcs-versioning, bundled with setuptools-scm>=10.0) to derive semver bumps from changelog fragment types automatically. - Add `prepare-release.yml` workflow: triggers on every push to main, computes the next version from fragments, creates/updates a `release-X.Y.Z` branch with towncrier-built changelog, and opens a PR. - Modify `test.yml`: set SETUPTOOLS_SCM_PRETEND_VERSION on release PRs so built packages have the exact release version. Add a `release-artifacts` job that creates/updates a draft GitHub release with the tested wheel+sdist after all checks pass. - Replace `deploy.yml`: trigger on `release: published` instead of tag push. Downloads bit-identical assets from the draft release, verifies PR checks, uploads to PyPI via trusted publishing, merges the PR, and cleans up the release branch. - Reclassify `632.removal.rst` as `632.feature.rst` (it's a deprecation warning, not a breaking removal). - Update RELEASING.rst and scripts/release.py to document and support the new automated flow (with manual fallback preserved). Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- .github/workflows/deploy.yml | 116 ++++++++++++++---- .github/workflows/prepare-release.yml | 113 +++++++++++++++++ .github/workflows/test.yml | 69 +++++++++++ RELEASING.rst | 55 ++++++--- .../{632.removal.rst => 632.feature.rst} | 0 pyproject.toml | 3 +- scripts/compute_version.py | 51 ++++++++ scripts/release.py | 54 +++++--- 8 files changed, 408 insertions(+), 53 deletions(-) create mode 100644 .github/workflows/prepare-release.yml rename changelog/{632.removal.rst => 632.feature.rst} (100%) create mode 100644 scripts/compute_version.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 22619482..69a8bd6b 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,42 +1,114 @@ name: deploy on: - push: - tags: - - "*" + release: + types: [published] -# Set permissions at the job level. permissions: {} jobs: - package: - name: Build & inspect our package. + verify: + name: Verify release PR status + if: github.repository == 'pytest-dev/pluggy' runs-on: ubuntu-latest permissions: - id-token: write - attestations: write + checks: read + pull-requests: read + outputs: + pr-number: ${{ steps.find-pr.outputs.number }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - persist-credentials: false - - uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0 - with: - attest-build-provenance-github: 'true' + - name: Find release PR + id: find-pr + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + VERSION="${GITHUB_REF_NAME}" + BRANCH="release-$VERSION" + PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') + if [ -z "$PR_NUMBER" ]; then + echo "::error::No open PR found for branch $BRANCH" + exit 1 + fi + echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" + echo "Found PR #$PR_NUMBER for $BRANCH" + + - name: Check PR CI status + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + PR_NUMBER="${{ steps.find-pr.outputs.number }}" + # Verify all required checks passed on the PR. + FAILED=$(gh pr checks "$PR_NUMBER" --json name,state \ + --jq '[.[] | select(.state != "SUCCESS" and .state != "SKIPPED")] | length') + if [ "$FAILED" -gt 0 ]; then + echo "::error::PR #$PR_NUMBER has failing checks — refusing to deploy." + gh pr checks "$PR_NUMBER" --json name,state \ + --jq '.[] | select(.state != "SUCCESS" and .state != "SKIPPED") | "\(.name): \(.state)"' + exit 1 + fi + echo "All PR checks passed." deploy: - needs: [package] - if: github.event_name == 'push' && startsWith(github.event.ref, 'refs/tags') && github.repository == 'pytest-dev/pluggy' + name: Publish to PyPI + needs: [verify] runs-on: ubuntu-latest permissions: id-token: write + attestations: write + contents: write steps: - - name: Download built packages from the check-package job. - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + - name: Download release assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + mkdir dist + gh release download "${GITHUB_REF_NAME}" --dir dist + echo "Downloaded assets:" + ls -la dist/ + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - name: Packages - path: dist - - name: Publish package + path: repo + sparse-checkout: | + .github + persist-credentials: false + + - name: Generate build provenance attestations + uses: actions/attest-build-provenance@c074443f1aee8d4aeeae555aebba3282517141b2 # v2.2.3 + with: + subject-path: "dist/*" + + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 with: attestations: true + + merge: + name: Merge release PR + needs: [verify, deploy] + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Merge PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + PR_NUMBER="${{ needs.verify.outputs.pr-number }}" + gh pr merge "$PR_NUMBER" --merge \ + --subject "Merge release ${{ github.ref_name }} (#$PR_NUMBER)" + echo "Merged PR #$PR_NUMBER" + + - name: Delete release branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_REPO: ${{ github.repository }} + run: | + BRANCH="release-${GITHUB_REF_NAME}" + gh api -X DELETE "repos/${GH_REPO}/git/refs/heads/$BRANCH" || true + echo "Deleted branch $BRANCH" diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml new file mode 100644 index 00000000..8629b819 --- /dev/null +++ b/.github/workflows/prepare-release.yml @@ -0,0 +1,113 @@ +name: prepare-release + +on: + push: + branches: + - main + +permissions: {} + +jobs: + prepare: + name: Prepare release PR + runs-on: ubuntu-latest + # Only run in the upstream repo, not forks. + if: github.repository == 'pytest-dev/pluggy' + permissions: + contents: write + pull-requests: write + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install "setuptools-scm[toml]>=10.0" packaging towncrier + + - name: Compute next version + id: version + run: | + VERSION=$(python scripts/compute_version.py) || exit 0 + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "branch=release-$VERSION" >> "$GITHUB_OUTPUT" + echo "Computed version: $VERSION" + + - name: Check for existing identical release branch + if: steps.version.outputs.version + id: check + run: | + VERSION="${{ steps.version.outputs.version }}" + BRANCH="release-$VERSION" + # If the release branch already exists and was built from this + # exact main commit, there is nothing to update. + if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then + BRANCH_BASE=$(git log "origin/$BRANCH" --format='%H' --max-count=1 --skip=1) + if [ "$BRANCH_BASE" = "${{ github.sha }}" ]; then + echo "Release branch $BRANCH already up-to-date with main." + echo "skip=true" >> "$GITHUB_OUTPUT" + fi + fi + + - name: Create release branch + if: steps.version.outputs.version && steps.check.outputs.skip != 'true' + run: | + VERSION="${{ steps.version.outputs.version }}" + BRANCH="${{ steps.version.outputs.branch }}" + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Start a fresh branch from the current main HEAD. + git checkout -B "$BRANCH" + + # Build the changelog (removes fragments, updates CHANGELOG.rst). + towncrier build --yes --version "$VERSION" + + git add -A + git commit -m "Preparing release $VERSION" + git push --force origin "$BRANCH" + + - name: Create or update pull request + if: steps.version.outputs.version && steps.check.outputs.skip != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + BRANCH="${{ steps.version.outputs.branch }}" + + EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty') + + BODY="$(cat <<'EOF' + Automated release PR created by CI. + + ## Checklist + + - [ ] Review the generated CHANGELOG.rst + - [ ] Ensure all CI checks pass + - [ ] When ready, **publish the draft GitHub release** to trigger PyPI deployment and merge + EOF + )" + + if [ -n "$EXISTING_PR" ]; then + gh pr edit "$EXISTING_PR" \ + --title "Release $VERSION" \ + --body "$BODY" + echo "Updated existing PR #$EXISTING_PR" + else + # Close any stale release PRs for a different version. + for pr in $(gh pr list --head 'release-' --json number,headRefName \ + --jq '.[] | select(.headRefName != "'"$BRANCH"'") | .number'); do + gh pr close "$pr" --comment "Superseded by release $VERSION." + done + + gh pr create \ + --head "$BRANCH" \ + --base main \ + --title "Release $VERSION" \ + --body "$BODY" + echo "Created new PR for $BRANCH" + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d91ed711..291aef15 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,17 @@ jobs: with: fetch-depth: 0 persist-credentials: false + + - name: Detect release branch version + id: release + if: startsWith(github.head_ref || '', 'release-') + run: | + VERSION="${GITHUB_HEAD_REF#release-}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0 + env: + SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PLUGGY: ${{ steps.release.outputs.version || '' }} test: needs: [check-package] @@ -145,3 +155,62 @@ jobs: fail_ci_if_error: true files: ./coverage.xml verbose: true + + release-artifacts: + name: Update draft GitHub release + needs: [check-package, test] + if: >- + github.repository == 'pytest-dev/pluggy' + && github.event_name == 'pull_request' + && startsWith(github.head_ref, 'release-') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Determine version from branch + id: version + run: | + VERSION="${GITHUB_HEAD_REF#release-}" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Download built packages + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: Packages + path: dist + + - name: Create or update draft release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${{ steps.version.outputs.version }}" + TAG="$VERSION" + + # Generate release notes from the PR's CHANGELOG.rst changes. + NOTES="Release $VERSION — built from \`${{ github.head_ref }}\` at ${{ github.event.pull_request.head.sha }}." + + if gh release view "$TAG" >/dev/null 2>&1; then + # Delete existing assets and re-upload. + gh release edit "$TAG" \ + --draft \ + --title "pluggy $VERSION" \ + --notes "$NOTES" \ + --target "${{ github.event.pull_request.head.sha }}" + # Remove old assets. + for asset in $(gh release view "$TAG" --json assets --jq '.assets[].name'); do + gh release delete-asset "$TAG" "$asset" --yes + done + else + gh release create "$TAG" \ + --draft \ + --title "pluggy $VERSION" \ + --notes "$NOTES" \ + --target "${{ github.event.pull_request.head.sha }}" + fi + + # Upload the wheel and sdist as release assets. + gh release upload "$TAG" dist/* --clobber diff --git a/RELEASING.rst b/RELEASING.rst index a6f58da9..63f8ac2f 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -1,28 +1,53 @@ Release Procedure ----------------- -#. Depending on the magnitude of the changes in the release, consider testing - some of the large downstream users of pluggy against the upcoming release. - You can do so with ``uv run downstream/run_downstream.py ``; - use ``--list`` to discover available recipes. +Releases are largely automated via GitHub Actions. The version is derived +automatically from changelog fragment types using the ``towncrier-fragments`` +version scheme (``feature`` → minor, ``bugfix`` → patch, ``removal`` → major). -#. From a clean work tree, execute:: +Automated flow +~~~~~~~~~~~~~~ - tox -e release -- VERSION +#. Contributors add changelog fragments (``changelog/..rst``) in + their pull requests as usual. + +#. Every push to ``main`` triggers the ``prepare-release`` workflow, which: + + * Computes the next version from the fragment types. + * Creates (or force-updates) a ``release-X.Y.Z`` branch with the + towncrier-built ``CHANGELOG.rst`` committed. + * Opens (or updates) a PR targeting ``main``. + +#. The normal CI (``test`` workflow) runs on the release PR. Once all checks + pass, the built wheel and sdist are uploaded to a **draft GitHub release**. - This will create the branch ready to be pushed. +#. A maintainer reviews the PR and, when satisfied, **publishes the draft + release** in the GitHub UI. -#. Open a PR targeting ``main``. +#. Publishing the release triggers the ``deploy`` workflow, which: -#. All tests must pass and the PR must be approved by at least another maintainer. + * Verifies all PR checks are green. + * Downloads the release assets (bit-identical to the draft). + * Uploads them to PyPI via trusted publishing. + * Merges the release PR into ``main``. + * Cleans up the release branch. -#. Publish to PyPI by pushing a tag:: +Downstream testing +~~~~~~~~~~~~~~~~~~ - git tag X.Y.Z release-X.Y.Z - git push git@github.com:pytest-dev/pluggy.git X.Y.Z +Before publishing a release, consider running downstream integration tests:: - The tag will trigger a new build, which will deploy to PyPI. + uv run downstream/run_downstream.py -#. Make sure it is `available on PyPI `_. +Use ``--list`` to discover available recipes. + +Manual fallback +~~~~~~~~~~~~~~~ + +For local testing or exceptional situations, the legacy script is still +available:: + + tox -e release -- VERSION -#. Merge the PR into ``main``, either manually or using GitHub's web interface. +This creates a ``release-VERSION`` branch with the changelog committed, +ready to be pushed and opened as a PR manually. diff --git a/changelog/632.removal.rst b/changelog/632.feature.rst similarity index 100% rename from changelog/632.removal.rst rename to changelog/632.feature.rst diff --git a/pyproject.toml b/pyproject.toml index 3adc4454..61b44ebc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] requires = [ "setuptools>=65.0", - "setuptools-scm[toml]>=8.0", + "setuptools-scm[toml]>=10.0", ] build-backend = "setuptools.build_meta" @@ -67,6 +67,7 @@ known-local-folder = ["pluggy"] lines-after-imports = 2 [tool.setuptools_scm] +version_scheme = "towncrier-fragments" [tool.uv] default-groups = ["dev", "testing"] diff --git a/scripts/compute_version.py b/scripts/compute_version.py new file mode 100644 index 00000000..7ff3bec9 --- /dev/null +++ b/scripts/compute_version.py @@ -0,0 +1,51 @@ +"""Compute the next release version from setuptools_scm + towncrier fragments. + +Uses the ``towncrier-fragments`` version scheme to derive a semver bump +from changelog fragment types, then strips the ``.devN+hash`` suffix to +produce a clean release version (e.g. ``1.7.0``). + +Exits non-zero when no changelog fragments are present. +""" + +from __future__ import annotations + +from pathlib import Path +import sys + + +def _changelog_fragments(changelog_dir: Path) -> list[Path]: + """Return fragment files, ignoring templates, README, and dotfiles.""" + return [ + p + for p in changelog_dir.iterdir() + if p.is_file() and not p.name.startswith(("_", ".")) and p.name != "README.rst" + ] + + +def compute_version() -> str | None: + """Return the next release version string, or *None* if there are no fragments.""" + repo_root = Path(__file__).resolve().parent.parent + changelog_dir = repo_root / "changelog" + + if not _changelog_fragments(changelog_dir): + return None + + from packaging.version import Version + from setuptools_scm import get_version + + raw = get_version(root=str(repo_root)) + ver = Version(raw) + return f"{ver.major}.{ver.minor}.{ver.micro}" + + +def main() -> int: + version = compute_version() + if version is None: + print("No changelog fragments found — nothing to release.", file=sys.stderr) + return 1 + print(version) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/release.py b/scripts/release.py index 84dc4675..60a9dceb 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,7 +1,18 @@ -""" -Release script. +"""Release script — local fallback for the automated CI pipeline. + +Typical CI usage (automated): + The ``prepare-release`` GitHub Actions workflow calls + ``scripts/compute_version.py`` and handles branching, towncrier, + and PR creation automatically. + +Manual usage:: + + tox -e release # auto-detect version from fragments + tox -e release -- 1.7.0 # explicit version override """ +from __future__ import annotations + import argparse from subprocess import check_call import sys @@ -12,8 +23,18 @@ from git import Repo +def compute_version_auto() -> str: + """Derive the next release version from changelog fragments.""" + from scripts.compute_version import compute_version + + version = compute_version() + if version is None: + raise RuntimeError("No changelog fragments found — cannot determine version.") + return version + + def create_branch(version: str) -> Repo: - """Create a fresh branch from upstream/main""" + """Create a fresh branch from upstream/main.""" repo = Repo.init(".") if repo.is_dirty(untracked_files=True): raise RuntimeError("Repository is dirty, please commit/stash your changes.") @@ -28,7 +49,7 @@ def create_branch(version: str) -> Repo: def get_upstream(repo: Repo) -> Remote: - """Find upstream repository for pluggy on the remotes""" + """Find upstream repository for pluggy on the remotes.""" for remote in repo.remotes: for url in remote.urls: if url.endswith(("pytest-dev/pluggy.git", "pytest-dev/pluggy")): @@ -37,7 +58,7 @@ def get_upstream(repo: Repo) -> Remote: def pre_release(version: str) -> None: - """Generates new docs, release announcements and creates a local tag.""" + """Create release branch and build changelog.""" create_branch(version) changelog(version, write_out=True) @@ -47,27 +68,30 @@ def pre_release(version: str) -> None: print(f"{Fore.GREEN}Please push your branch to your fork and open a PR.") -def changelog(version: str, write_out: bool = False) -> None: - if write_out: - addopts = [] - else: - addopts = ["--draft"] +def changelog(version: str, *, write_out: bool = False) -> None: + addopts: list[str] = [] if write_out else ["--draft"] print(f"{Fore.CYAN}Generating CHANGELOG") - check_call(["towncrier", "build", "--yes", "--version", version] + addopts) + check_call(["towncrier", "build", "--yes", "--version", version, *addopts]) def main() -> int: init(autoreset=True) parser = argparse.ArgumentParser() - parser.add_argument("version", help="Release version") + parser.add_argument( + "version", + nargs="?", + default=None, + help="Release version (auto-detected from fragments if omitted)", + ) options = parser.parse_args() try: - pre_release(options.version) + version = options.version or compute_version_auto() + print(f"{Fore.CYAN}Release version: {version}") + pre_release(version) except RuntimeError as e: print(f"{Fore.RED}ERROR: {e}") return 1 - else: - return 0 + return 0 if __name__ == "__main__": From 6d4845ea8695ee437f89bf42987695b12cf66695 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 27 May 2026 12:48:01 +0200 Subject: [PATCH 3/8] fix: only lock on published releases, keep updating drafts The draft release is a staging area that gets continuously replaced as main evolves. Only a published (non-draft) release acts as a lock: - prepare-release: skips only when a published release exists for the version (meaning it is already on PyPI). Draft releases are freely overwritten by force-updating the release branch. - release-artifacts: creates or replaces the draft after every green test run. Skips only when a published release exists. To start a fresh release cycle after publishing, the next push to main with new fragments will compute a new version automatically. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- .github/workflows/prepare-release.yml | 14 +++++++++++++- .github/workflows/test.yml | 17 ++++++++--------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 8629b819..1ee97ee7 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -36,12 +36,24 @@ jobs: echo "branch=release-$VERSION" >> "$GITHUB_OUTPUT" echo "Computed version: $VERSION" - - name: Check for existing identical release branch + - name: Check if release is already published or branch is up-to-date if: steps.version.outputs.version id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | VERSION="${{ steps.version.outputs.version }}" BRANCH="release-$VERSION" + + # A published (non-draft) release means the version is locked in + # and already on PyPI — do not touch the release branch. + IS_DRAFT=$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null || echo "") + if [ "$IS_DRAFT" = "false" ]; then + echo "::notice::Release $VERSION is already published — nothing to do." + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # If the release branch already exists and was built from this # exact main commit, there is nothing to update. if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 291aef15..add3b0c7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -189,28 +189,27 @@ jobs: run: | VERSION="${{ steps.version.outputs.version }}" TAG="$VERSION" - - # Generate release notes from the PR's CHANGELOG.rst changes. NOTES="Release $VERSION — built from \`${{ github.head_ref }}\` at ${{ github.event.pull_request.head.sha }}." - if gh release view "$TAG" >/dev/null 2>&1; then - # Delete existing assets and re-upload. + if gh release view "$TAG" --json isDraft --jq '.isDraft' 2>/dev/null | grep -q 'true'; then + # Update existing draft: replace assets with freshly tested ones. gh release edit "$TAG" \ --draft \ --title "pluggy $VERSION" \ --notes "$NOTES" \ --target "${{ github.event.pull_request.head.sha }}" - # Remove old assets. for asset in $(gh release view "$TAG" --json assets --jq '.assets[].name'); do gh release delete-asset "$TAG" "$asset" --yes done + gh release upload "$TAG" dist/* + elif gh release view "$TAG" >/dev/null 2>&1; then + # A published (non-draft) release exists — version is locked. + echo "::notice::Release $VERSION is already published — skipping." else - gh release create "$TAG" \ + # No release exists yet — create a fresh draft. + gh release create "$TAG" dist/* \ --draft \ --title "pluggy $VERSION" \ --notes "$NOTES" \ --target "${{ github.event.pull_request.head.sha }}" fi - - # Upload the wheel and sdist as release assets. - gh release upload "$TAG" dist/* --clobber From 5f4590eae22efe754f2e03d2682c5f13fc674ef8 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 27 May 2026 12:59:35 +0200 Subject: [PATCH 4/8] fix: validate release branch version is semver before using it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `release-` prefix match was too broad — branches like `release-proposer` would set PRETEND_VERSION to `proposer`, breaking the build. Now validate that the suffix matches `^[0-9]+\.[0-9]+\.[0-9]+` before treating it as a version. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- .github/workflows/test.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index add3b0c7..1e1cb996 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,9 @@ jobs: if: startsWith(github.head_ref || '', 'release-') run: | VERSION="${GITHUB_HEAD_REF#release-}" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" + if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + fi - uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0 env: @@ -171,19 +173,26 @@ jobs: id: version run: | VERSION="${GITHUB_HEAD_REF#release-}" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then + echo "::notice::Branch $GITHUB_HEAD_REF is not a release version branch — skipping." + exit 0 + fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: steps.version.outputs.version with: persist-credentials: false - name: Download built packages + if: steps.version.outputs.version uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: Packages path: dist - name: Create or update draft release + if: steps.version.outputs.version env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | From 3456ab741c30626e54fcad2d65544c441a363ab5 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 27 May 2026 13:08:07 +0200 Subject: [PATCH 5/8] refactor: rename changelog/ to changelog.d/, use setuptools_scm CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename `changelog/` to `changelog.d/` to match the convention expected by the `towncrier-fragments` version scheme in vcs-versioning. This makes `python -m setuptools_scm --strip-dev` correctly detect fragment types and compute the release version. - Remove `scripts/compute_version.py` — the setuptools_scm CLI with `--strip-dev` replaces it entirely. - Update `prepare-release.yml` and `scripts/release.py` to use the CLI. - Add `setuptools-scm[toml]>=10.0` to the tox release env deps. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- .github/workflows/prepare-release.yml | 5 ++- RELEASING.rst | 2 +- {changelog => changelog.d}/186.doc.rst | 0 {changelog => changelog.d}/431.bugfix.rst | 0 {changelog => changelog.d}/522.doc.rst | 0 {changelog => changelog.d}/590.trivial.rst | 0 {changelog => changelog.d}/629.bugfix.rst | 0 {changelog => changelog.d}/632.feature.rst | 0 {changelog => changelog.d}/README.rst | 0 {changelog => changelog.d}/_template.rst | 0 pyproject.toml | 4 +- scripts/compute_version.py | 51 ---------------------- scripts/release.py | 23 ++++++---- tox.ini | 1 + 14 files changed, 21 insertions(+), 65 deletions(-) rename {changelog => changelog.d}/186.doc.rst (100%) rename {changelog => changelog.d}/431.bugfix.rst (100%) rename {changelog => changelog.d}/522.doc.rst (100%) rename {changelog => changelog.d}/590.trivial.rst (100%) rename {changelog => changelog.d}/629.bugfix.rst (100%) rename {changelog => changelog.d}/632.feature.rst (100%) rename {changelog => changelog.d}/README.rst (100%) rename {changelog => changelog.d}/_template.rst (100%) delete mode 100644 scripts/compute_version.py diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 1ee97ee7..af896000 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -26,12 +26,13 @@ jobs: python-version: "3.13" - name: Install dependencies - run: pip install "setuptools-scm[toml]>=10.0" packaging towncrier + run: pip install "setuptools-scm[toml]>=10.0" towncrier - name: Compute next version id: version run: | - VERSION=$(python scripts/compute_version.py) || exit 0 + VERSION=$(python -m setuptools_scm --strip-dev) || exit 0 + if [ -z "$VERSION" ]; then exit 0; fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "branch=release-$VERSION" >> "$GITHUB_OUTPUT" echo "Computed version: $VERSION" diff --git a/RELEASING.rst b/RELEASING.rst index 63f8ac2f..7978bb42 100644 --- a/RELEASING.rst +++ b/RELEASING.rst @@ -8,7 +8,7 @@ version scheme (``feature`` → minor, ``bugfix`` → patch, ``removal`` → maj Automated flow ~~~~~~~~~~~~~~ -#. Contributors add changelog fragments (``changelog/..rst``) in +#. Contributors add changelog fragments (``changelog.d/..rst``) in their pull requests as usual. #. Every push to ``main`` triggers the ``prepare-release`` workflow, which: diff --git a/changelog/186.doc.rst b/changelog.d/186.doc.rst similarity index 100% rename from changelog/186.doc.rst rename to changelog.d/186.doc.rst diff --git a/changelog/431.bugfix.rst b/changelog.d/431.bugfix.rst similarity index 100% rename from changelog/431.bugfix.rst rename to changelog.d/431.bugfix.rst diff --git a/changelog/522.doc.rst b/changelog.d/522.doc.rst similarity index 100% rename from changelog/522.doc.rst rename to changelog.d/522.doc.rst diff --git a/changelog/590.trivial.rst b/changelog.d/590.trivial.rst similarity index 100% rename from changelog/590.trivial.rst rename to changelog.d/590.trivial.rst diff --git a/changelog/629.bugfix.rst b/changelog.d/629.bugfix.rst similarity index 100% rename from changelog/629.bugfix.rst rename to changelog.d/629.bugfix.rst diff --git a/changelog/632.feature.rst b/changelog.d/632.feature.rst similarity index 100% rename from changelog/632.feature.rst rename to changelog.d/632.feature.rst diff --git a/changelog/README.rst b/changelog.d/README.rst similarity index 100% rename from changelog/README.rst rename to changelog.d/README.rst diff --git a/changelog/_template.rst b/changelog.d/_template.rst similarity index 100% rename from changelog/_template.rst rename to changelog.d/_template.rst diff --git a/pyproject.toml b/pyproject.toml index 61b44ebc..f36bf2c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,9 +76,9 @@ default-groups = ["dev", "testing"] package = "pluggy" package_dir = "src/pluggy" filename = "CHANGELOG.rst" -directory = "changelog/" +directory = "changelog.d/" title_format = "pluggy {version} ({project_date})" -template = "changelog/_template.rst" +template = "changelog.d/_template.rst" [[tool.towncrier.type]] directory = "removal" diff --git a/scripts/compute_version.py b/scripts/compute_version.py deleted file mode 100644 index 7ff3bec9..00000000 --- a/scripts/compute_version.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Compute the next release version from setuptools_scm + towncrier fragments. - -Uses the ``towncrier-fragments`` version scheme to derive a semver bump -from changelog fragment types, then strips the ``.devN+hash`` suffix to -produce a clean release version (e.g. ``1.7.0``). - -Exits non-zero when no changelog fragments are present. -""" - -from __future__ import annotations - -from pathlib import Path -import sys - - -def _changelog_fragments(changelog_dir: Path) -> list[Path]: - """Return fragment files, ignoring templates, README, and dotfiles.""" - return [ - p - for p in changelog_dir.iterdir() - if p.is_file() and not p.name.startswith(("_", ".")) and p.name != "README.rst" - ] - - -def compute_version() -> str | None: - """Return the next release version string, or *None* if there are no fragments.""" - repo_root = Path(__file__).resolve().parent.parent - changelog_dir = repo_root / "changelog" - - if not _changelog_fragments(changelog_dir): - return None - - from packaging.version import Version - from setuptools_scm import get_version - - raw = get_version(root=str(repo_root)) - ver = Version(raw) - return f"{ver.major}.{ver.minor}.{ver.micro}" - - -def main() -> int: - version = compute_version() - if version is None: - print("No changelog fragments found — nothing to release.", file=sys.stderr) - return 1 - print(version) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/scripts/release.py b/scripts/release.py index 60a9dceb..3f9e5051 100644 --- a/scripts/release.py +++ b/scripts/release.py @@ -1,9 +1,9 @@ """Release script — local fallback for the automated CI pipeline. Typical CI usage (automated): - The ``prepare-release`` GitHub Actions workflow calls - ``scripts/compute_version.py`` and handles branching, towncrier, - and PR creation automatically. + The ``prepare-release`` GitHub Actions workflow uses + ``python -m setuptools_scm --strip-dev`` and handles branching, + towncrier, and PR creation automatically. Manual usage:: @@ -15,6 +15,7 @@ import argparse from subprocess import check_call +from subprocess import check_output import sys from colorama import Fore @@ -24,12 +25,16 @@ def compute_version_auto() -> str: - """Derive the next release version from changelog fragments.""" - from scripts.compute_version import compute_version - - version = compute_version() - if version is None: - raise RuntimeError("No changelog fragments found — cannot determine version.") + """Derive the next release version via ``setuptools_scm --strip-dev``.""" + version = ( + check_output( + [sys.executable, "-m", "setuptools_scm", "--strip-dev"], + ) + .decode() + .strip() + ) + if not version: + raise RuntimeError("setuptools_scm returned an empty version.") return version diff --git a/tox.ini b/tox.ini index a09b09cc..fa44ce9e 100644 --- a/tox.ini +++ b/tox.ini @@ -52,5 +52,6 @@ passenv = * deps = colorama gitpython + setuptools-scm[toml]>=10.0 towncrier commands = python scripts/release.py {posargs} From a41b803c78114fd6e4812f244db641c0a15c0a3c Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 27 May 2026 13:15:26 +0200 Subject: [PATCH 6/8] fix: address review comments on release workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test.yml: Set SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PLUGGY via GITHUB_ENV only when a valid release version is detected, avoiding an empty-string env var that would break setuptools-scm on non-release branches. - prepare-release.yml: Fix stale release PR cleanup — `gh pr list --head` does exact matching, so list all PRs and filter by `startswith("release-")` in jq instead. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- .github/workflows/prepare-release.yml | 4 ++-- .github/workflows/test.yml | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index af896000..187ab09e 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -112,8 +112,8 @@ jobs: echo "Updated existing PR #$EXISTING_PR" else # Close any stale release PRs for a different version. - for pr in $(gh pr list --head 'release-' --json number,headRefName \ - --jq '.[] | select(.headRefName != "'"$BRANCH"'") | .number'); do + for pr in $(gh pr list --json number,headRefName \ + --jq '.[] | select(.headRefName | startswith("release-")) | select(.headRefName != "'"$BRANCH"'") | .number'); do gh pr close "$pr" --comment "Superseded by release $VERSION." done diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e1cb996..86bc4f21 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,11 +30,10 @@ jobs: VERSION="${GITHUB_HEAD_REF#release-}" if [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]]; then echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PLUGGY=$VERSION" >> "$GITHUB_ENV" fi - uses: hynek/build-and-inspect-python-package@fe0a0fb1925ca263d076ca4f2c13e93a6e92a33e # v2.17.0 - env: - SETUPTOOLS_SCM_PRETEND_VERSION_FOR_PLUGGY: ${{ steps.release.outputs.version || '' }} test: needs: [check-package] From 4d432a7c4a7e9d7d23f5201f286e919a424df826 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 27 May 2026 13:23:23 +0200 Subject: [PATCH 7/8] refactor: replace gh CLI shell scripts with actions/github-script Use the typed Octokit REST API via actions/github-script@v9 for all GitHub API interactions in the release workflows. This eliminates fragile shell+jq piping (including the --head prefix-matching bug) and makes the logic more readable and maintainable. Shell scripts are kept only for git operations, Python tooling, and simple asset downloads where they remain the better fit. Converted steps: - prepare-release.yml: release-published check, branch up-to-date check, PR create/update/close-stale - test.yml: draft release create/update with asset management - deploy.yml: PR lookup + CI status verification, PR merge + branch cleanup Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- .github/workflows/deploy.yml | 113 ++++++++++-------- .github/workflows/prepare-release.yml | 163 ++++++++++++++++---------- .github/workflows/test.yml | 97 ++++++++++----- 3 files changed, 235 insertions(+), 138 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 69a8bd6b..f40a5241 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -15,40 +15,48 @@ jobs: checks: read pull-requests: read outputs: - pr-number: ${{ steps.find-pr.outputs.number }} + pr-number: ${{ steps.verify.outputs.pr-number }} steps: - - name: Find release PR - id: find-pr + - name: Find release PR and verify CI status + id: verify + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - run: | - VERSION="${GITHUB_REF_NAME}" - BRANCH="release-$VERSION" - PR_NUMBER=$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number // empty') - if [ -z "$PR_NUMBER" ]; then - echo "::error::No open PR found for branch $BRANCH" - exit 1 - fi - echo "number=$PR_NUMBER" >> "$GITHUB_OUTPUT" - echo "Found PR #$PR_NUMBER for $BRANCH" + VERSION: ${{ github.ref_name }} + with: + script: | + const { VERSION } = process.env; + const branch = `release-${VERSION}`; - - name: Check PR CI status - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - run: | - PR_NUMBER="${{ steps.find-pr.outputs.number }}" - # Verify all required checks passed on the PR. - FAILED=$(gh pr checks "$PR_NUMBER" --json name,state \ - --jq '[.[] | select(.state != "SUCCESS" and .state != "SKIPPED")] | length') - if [ "$FAILED" -gt 0 ]; then - echo "::error::PR #$PR_NUMBER has failing checks — refusing to deploy." - gh pr checks "$PR_NUMBER" --json name,state \ - --jq '.[] | select(.state != "SUCCESS" and .state != "SKIPPED") | "\(.name): \(.state)"' - exit 1 - fi - echo "All PR checks passed." + // Find the open release PR. + const { data: prs } = await github.rest.pulls.list({ + ...context.repo, + head: `${context.repo.owner}:${branch}`, + state: 'open', + }); + if (prs.length === 0) { + core.setFailed(`No open PR found for branch ${branch}`); + return; + } + const prNumber = prs[0].number; + core.setOutput('pr-number', prNumber); + core.info(`Found PR #${prNumber} for ${branch}`); + + // Verify all required checks passed on the PR head commit. + const { data: checks } = await github.rest.checks.listForRef({ + ...context.repo, + ref: prs[0].head.sha, + }); + const failed = checks.check_runs.filter( + cr => cr.conclusion !== 'success' && cr.conclusion !== 'skipped' && cr.conclusion !== null + ); + if (failed.length > 0) { + for (const cr of failed) { + core.error(`${cr.name}: ${cr.conclusion}`); + } + core.setFailed(`PR #${prNumber} has ${failed.length} failing check(s) — refusing to deploy.`); + return; + } + core.info('All PR checks passed.'); deploy: name: Publish to PyPI @@ -66,7 +74,6 @@ jobs: run: | mkdir dist gh release download "${GITHUB_REF_NAME}" --dir dist - echo "Downloaded assets:" ls -la dist/ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -94,21 +101,31 @@ jobs: contents: write pull-requests: write steps: - - name: Merge PR + - name: Merge PR and delete release branch + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - run: | - PR_NUMBER="${{ needs.verify.outputs.pr-number }}" - gh pr merge "$PR_NUMBER" --merge \ - --subject "Merge release ${{ github.ref_name }} (#$PR_NUMBER)" - echo "Merged PR #$PR_NUMBER" + PR_NUMBER: ${{ needs.verify.outputs.pr-number }} + VERSION: ${{ github.ref_name }} + with: + script: | + const prNumber = parseInt(process.env.PR_NUMBER, 10); + const branch = `release-${process.env.VERSION}`; - - name: Delete release branch - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - run: | - BRANCH="release-${GITHUB_REF_NAME}" - gh api -X DELETE "repos/${GH_REPO}/git/refs/heads/$BRANCH" || true - echo "Deleted branch $BRANCH" + await github.rest.pulls.merge({ + ...context.repo, + pull_number: prNumber, + merge_method: 'merge', + commit_title: `Merge release ${process.env.VERSION} (#${prNumber})`, + }); + core.info(`Merged PR #${prNumber}`); + + try { + await github.rest.git.deleteRef({ + ...context.repo, + ref: `heads/${branch}`, + }); + core.info(`Deleted branch ${branch}`); + } catch (e) { + if (e.status !== 422) throw e; + core.info(`Branch ${branch} already deleted.`); + } diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index 187ab09e..de0898d8 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -40,30 +40,46 @@ jobs: - name: Check if release is already published or branch is up-to-date if: steps.version.outputs.version id: check + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ steps.version.outputs.version }}" - BRANCH="release-$VERSION" - - # A published (non-draft) release means the version is locked in - # and already on PyPI — do not touch the release branch. - IS_DRAFT=$(gh release view "$VERSION" --json isDraft --jq '.isDraft' 2>/dev/null || echo "") - if [ "$IS_DRAFT" = "false" ]; then - echo "::notice::Release $VERSION is already published — nothing to do." - echo "skip=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - # If the release branch already exists and was built from this - # exact main commit, there is nothing to update. - if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then - BRANCH_BASE=$(git log "origin/$BRANCH" --format='%H' --max-count=1 --skip=1) - if [ "$BRANCH_BASE" = "${{ github.sha }}" ]; then - echo "Release branch $BRANCH already up-to-date with main." - echo "skip=true" >> "$GITHUB_OUTPUT" - fi - fi + VERSION: ${{ steps.version.outputs.version }} + BRANCH: release-${{ steps.version.outputs.version }} + PUSH_SHA: ${{ github.sha }} + with: + script: | + const { VERSION, BRANCH, PUSH_SHA } = process.env; + + // A published (non-draft) release means the version is locked in. + try { + const release = await github.rest.repos.getReleaseByTag({ + ...context.repo, + tag: VERSION, + }); + if (!release.data.draft) { + core.notice(`Release ${VERSION} is already published — nothing to do.`); + core.setOutput('skip', 'true'); + return; + } + } catch (e) { + if (e.status !== 404) throw e; + } + + // If the release branch already exists and was built from this + // exact main commit, there is nothing to update. + try { + const commits = await github.rest.repos.listCommits({ + ...context.repo, + sha: BRANCH, + per_page: 2, + }); + if (commits.data.length >= 2 && commits.data[1].sha === PUSH_SHA) { + core.info(`Release branch ${BRANCH} already up-to-date with main.`); + core.setOutput('skip', 'true'); + return; + } + } catch (e) { + if (e.status !== 404 && e.status !== 409) throw e; + } - name: Create release branch if: steps.version.outputs.version && steps.check.outputs.skip != 'true' @@ -74,10 +90,8 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Start a fresh branch from the current main HEAD. git checkout -B "$BRANCH" - # Build the changelog (removes fragments, updates CHANGELOG.rst). towncrier build --yes --version "$VERSION" git add -A @@ -86,41 +100,66 @@ jobs: - name: Create or update pull request if: steps.version.outputs.version && steps.check.outputs.skip != 'true' + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ steps.version.outputs.version }}" - BRANCH="${{ steps.version.outputs.branch }}" - - EXISTING_PR=$(gh pr list --head "$BRANCH" --json number --jq '.[0].number // empty') - - BODY="$(cat <<'EOF' - Automated release PR created by CI. - - ## Checklist - - - [ ] Review the generated CHANGELOG.rst - - [ ] Ensure all CI checks pass - - [ ] When ready, **publish the draft GitHub release** to trigger PyPI deployment and merge - EOF - )" - - if [ -n "$EXISTING_PR" ]; then - gh pr edit "$EXISTING_PR" \ - --title "Release $VERSION" \ - --body "$BODY" - echo "Updated existing PR #$EXISTING_PR" - else - # Close any stale release PRs for a different version. - for pr in $(gh pr list --json number,headRefName \ - --jq '.[] | select(.headRefName | startswith("release-")) | select(.headRefName != "'"$BRANCH"'") | .number'); do - gh pr close "$pr" --comment "Superseded by release $VERSION." - done - - gh pr create \ - --head "$BRANCH" \ - --base main \ - --title "Release $VERSION" \ - --body "$BODY" - echo "Created new PR for $BRANCH" - fi + VERSION: ${{ steps.version.outputs.version }} + BRANCH: release-${{ steps.version.outputs.version }} + with: + script: | + const { VERSION, BRANCH } = process.env; + const body = [ + 'Automated release PR created by CI.', + '', + '## Checklist', + '', + '- [ ] Review the generated CHANGELOG.rst', + '- [ ] Ensure all CI checks pass', + '- [ ] When ready, **publish the draft GitHub release** to trigger PyPI deployment and merge', + ].join('\n'); + + // Look for an existing PR from this release branch. + const { data: prs } = await github.rest.pulls.list({ + ...context.repo, + head: `${context.repo.owner}:${BRANCH}`, + state: 'open', + }); + + if (prs.length > 0) { + await github.rest.pulls.update({ + ...context.repo, + pull_number: prs[0].number, + title: `Release ${VERSION}`, + body, + }); + core.info(`Updated existing PR #${prs[0].number}`); + } else { + // Close any stale release PRs for a different version. + const { data: allPrs } = await github.rest.pulls.list({ + ...context.repo, + state: 'open', + }); + for (const pr of allPrs) { + if (pr.head.ref.startsWith('release-') && pr.head.ref !== BRANCH) { + await github.rest.pulls.update({ + ...context.repo, + pull_number: pr.number, + state: 'closed', + }); + await github.rest.issues.createComment({ + ...context.repo, + issue_number: pr.number, + body: `Superseded by release ${VERSION}.`, + }); + core.info(`Closed stale PR #${pr.number} (${pr.head.ref})`); + } + } + + const { data: newPr } = await github.rest.pulls.create({ + ...context.repo, + head: BRANCH, + base: 'main', + title: `Release ${VERSION}`, + body, + }); + core.info(`Created new PR #${newPr.number} for ${BRANCH}`); + } diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 86bc4f21..d97b53a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -192,32 +192,73 @@ jobs: - name: Create or update draft release if: steps.version.outputs.version + uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0 env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - VERSION="${{ steps.version.outputs.version }}" - TAG="$VERSION" - NOTES="Release $VERSION — built from \`${{ github.head_ref }}\` at ${{ github.event.pull_request.head.sha }}." - - if gh release view "$TAG" --json isDraft --jq '.isDraft' 2>/dev/null | grep -q 'true'; then - # Update existing draft: replace assets with freshly tested ones. - gh release edit "$TAG" \ - --draft \ - --title "pluggy $VERSION" \ - --notes "$NOTES" \ - --target "${{ github.event.pull_request.head.sha }}" - for asset in $(gh release view "$TAG" --json assets --jq '.assets[].name'); do - gh release delete-asset "$TAG" "$asset" --yes - done - gh release upload "$TAG" dist/* - elif gh release view "$TAG" >/dev/null 2>&1; then - # A published (non-draft) release exists — version is locked. - echo "::notice::Release $VERSION is already published — skipping." - else - # No release exists yet — create a fresh draft. - gh release create "$TAG" dist/* \ - --draft \ - --title "pluggy $VERSION" \ - --notes "$NOTES" \ - --target "${{ github.event.pull_request.head.sha }}" - fi + VERSION: ${{ steps.version.outputs.version }} + HEAD_REF: ${{ github.head_ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + with: + script: | + const fs = require('fs'); + const path = require('path'); + const { VERSION, HEAD_REF, HEAD_SHA } = process.env; + const tag = VERSION; + const title = `pluggy ${VERSION}`; + const body = `Release ${VERSION} — built from \`${HEAD_REF}\` at ${HEAD_SHA}.`; + + let release; + try { + const { data } = await github.rest.repos.getReleaseByTag({ + ...context.repo, + tag, + }); + release = data; + } catch (e) { + if (e.status !== 404) throw e; + } + + if (release && !release.draft) { + core.notice(`Release ${VERSION} is already published — skipping.`); + return; + } + + if (release) { + // Update existing draft: replace assets with freshly tested ones. + await github.rest.repos.updateRelease({ + ...context.repo, + release_id: release.id, + tag_name: tag, + name: title, + body, + draft: true, + target_commitish: HEAD_SHA, + }); + for (const asset of release.assets) { + await github.rest.repos.deleteReleaseAsset({ + ...context.repo, + asset_id: asset.id, + }); + } + } else { + const { data } = await github.rest.repos.createRelease({ + ...context.repo, + tag_name: tag, + name: title, + body, + draft: true, + target_commitish: HEAD_SHA, + }); + release = data; + } + + // Upload all assets from dist/. + for (const file of fs.readdirSync('dist')) { + const filePath = path.join('dist', file); + await github.rest.repos.uploadReleaseAsset({ + ...context.repo, + release_id: release.id, + name: file, + data: fs.readFileSync(filePath), + }); + core.info(`Uploaded ${file}`); + } From 62cfb6e56c17c1f0b00918550a325b16b263b761 Mon Sep 17 00:00:00 2001 From: Ronny Pfannschmidt Date: Wed, 27 May 2026 13:28:50 +0200 Subject: [PATCH 8/8] ci: run downstream tests on release branch PRs Release PRs should validate downstream compatibility before publishing, so trigger the downstream workflow unconditionally when the head branch starts with `release-`. Co-authored-by: Cursor AI Co-authored-by: Anthropic Claude Opus 4 --- .github/workflows/downstream.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/downstream.yml b/.github/workflows/downstream.yml index 9b095bbc..ee4c5394 100644 --- a/.github/workflows/downstream.yml +++ b/.github/workflows/downstream.yml @@ -26,7 +26,9 @@ jobs: timeout-minutes: 120 if: >- github.event_name == 'workflow_dispatch' - || (github.event_name == 'pull_request' && contains(github.head_ref, 'downstream')) + || (github.event_name == 'pull_request' + && (startsWith(github.head_ref, 'release-') + || contains(github.head_ref, 'downstream'))) strategy: fail-fast: false matrix: