From 0709c248875caadfb986eed28e52439215b7e848 Mon Sep 17 00:00:00 2001 From: ScottVR Date: Mon, 19 Oct 2026 05:17:58 -0500 Subject: [PATCH] Release on version bump instead of on tag push; weekly CI drift check Publish to PyPI now triggers on push to main (and workflow_dispatch). A check job reads project.version from pyproject.toml and only proceeds when no v tag exists and the version is not already on PyPI. The release path then runs tests, builds and validates the dists, publishes behind the pypi environment approval, and finally creates the v tag and a GitHub Release with notes extracted from PYPI-RELEASE-NOTES.md and the dist files attached. This removes the hand-maintained tag/version coupling that caused the v2.2.0 (version mismatch) and v2.0.x publish failures. test.yaml gains a weekly cron so dependency/stub drift (the cause of the v2.2.1 failure) shows up between pushes instead of on release day. --- .github/workflows/publish-pypi.yml | 200 ++++++++++++++++++++++------- .github/workflows/test.yaml | 4 + 2 files changed, 156 insertions(+), 48 deletions(-) diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index 918260a..e39a865 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -1,92 +1,124 @@ name: Publish to PyPI +# Release model: bump `project.version` in pyproject.toml, merge/push to main. +# This workflow runs on every push to main; it only releases when the version +# in pyproject.toml has no matching `v` git tag yet. The release then: +# 1. runs the test suite, +# 2. builds and validates sdist + wheel, +# 3. publishes to PyPI via Trusted Publishing (pauses for the `pypi` +# environment approval), +# 4. creates the `v` tag and a GitHub Release from +# PYPI-RELEASE-NOTES.md, with the dist files attached. +# `workflow_dispatch` is a manual re-run of the same logic (main only). + on: push: - tags: - - "v[0-9]+.[0-9]+.[0-9]+*" + branches: [main] + workflow_dispatch: permissions: contents: read -jobs: - test: - name: Run test suite - uses: ./.github/workflows/test.yaml +concurrency: + group: publish-pypi + cancel-in-progress: false - build: - name: Validate release and build distributions +jobs: + check: + name: Decide whether this commit is a release runs-on: ubuntu-latest - permissions: - contents: read + if: github.ref == 'refs/heads/main' + outputs: + release: ${{ steps.decide.outputs.release }} + version: ${{ steps.decide.outputs.version }} steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 with: - fetch-depth: 0 persist-credentials: false - - name: Ensure tagged commit is on main - run: | - set -euo pipefail - commit="$(git rev-parse 'HEAD^{commit}')" - if ! git merge-base --is-ancestor "$commit" origin/main; then - echo "Tag ${GITHUB_REF_NAME} points to ${commit}, which is not on main." - echo "Merge to main first, then tag the commit on main." - exit 1 - fi - echo "Release gate passed: ${commit} is on main." - - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.14" - - name: Validate tag matches package version + - name: Compare pyproject version against git tags and PyPI + id: decide + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - pkg_version="$(python - <<'PY' + version="$(python - <<'PY' import tomllib with open("pyproject.toml", "rb") as pyproject_file: pyproject = tomllib.load(pyproject_file) - print(pyproject.get("project", {}).get("version", "")) + print(pyproject["project"]["version"]) PY )" - if [ -z "$pkg_version" ]; then - echo "Could not resolve package version from pyproject.toml" + if [ -z "$version" ]; then + echo "::error::Could not resolve project.version from pyproject.toml" exit 1 fi - if [ "${GITHUB_REF_NAME}" != "v${pkg_version}" ]; then - echo "Tag/version mismatch: tag=${GITHUB_REF_NAME} expected=v${pkg_version}" + tag="v${version}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + + if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${tag}" >/dev/null 2>&1; then + echo "Tag ${tag} already exists; pyproject version ${version} was already released. Nothing to do." + echo "release=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if curl -fsSL "https://pypi.org/pypi/phart/${version}/json" >/dev/null 2>&1; then + echo "::error::phart ${version} is already on PyPI but tag ${tag} does not exist." + echo "Tag the commit that was published, then this workflow will go idle:" + echo " git tag ${tag} && git push origin ${tag}" exit 1 fi - echo "Tag/version check passed: ${GITHUB_REF_NAME}" + + echo "Version ${version}: no ${tag} tag and not on PyPI. Releasing." + echo "release=true" >> "$GITHUB_OUTPUT" + + test: + name: Run test suite + needs: check + if: needs.check.outputs.release == 'true' + uses: ./.github/workflows/test.yaml + + build: + name: Build and validate distributions + needs: check + if: needs.check.outputs.release == 'true' + runs-on: ubuntu-latest + env: + RELEASE_VERSION: ${{ needs.check.outputs.version }} + + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.14" - name: Validate source module entrypoint version run: | set -euo pipefail python -m pip install --upgrade pip python -m pip install -e . - pkg_version="$(python - <<'PY' - import tomllib - - with open("pyproject.toml", "rb") as pyproject_file: - pyproject = tomllib.load(pyproject_file) - - print(pyproject.get("project", {}).get("version", "")) - PY - )" module_version="$(PYTHONPATH=src python -m phart --version)" - if [ "$module_version" != "$pkg_version" ]; then - echo "Module entrypoint/version mismatch: module=$module_version package=$pkg_version" + if [ "$module_version" != "$RELEASE_VERSION" ]; then + echo "::error::Module entrypoint/version mismatch: module=$module_version package=$RELEASE_VERSION" exit 1 fi echo "Source module entrypoint check passed: $module_version" - name: Build sdist and wheel run: | - python -m pip install --upgrade pip + set -euo pipefail python -m pip install build twine python -m build twine check dist/* @@ -95,15 +127,13 @@ jobs: run: | set -euo pipefail python - <<'PY' + import os import tarfile - import tomllib from email.parser import Parser from pathlib import Path from zipfile import ZipFile - with open("pyproject.toml", "rb") as pyproject_file: - expected = tomllib.load(pyproject_file)["project"]["version"] - + expected = os.environ["RELEASE_VERSION"] versions = {} for wheel in Path("dist").glob("*.whl"): @@ -158,11 +188,12 @@ jobs: publish: name: Publish to PyPI (Trusted Publishing) - needs: [test, build] + needs: [check, test, build] + if: needs.check.outputs.release == 'true' runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/phart + url: https://pypi.org/project/phart/${{ needs.check.outputs.version }}/ permissions: id-token: write @@ -177,3 +208,76 @@ jobs: uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: attestations: true + + release: + name: Tag and create GitHub Release + needs: [check, publish] + if: needs.check.outputs.release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + env: + RELEASE_VERSION: ${{ needs.check.outputs.version }} + + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Download dist artifacts + uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + with: + name: dist + path: dist/ + + - name: Extract release notes for this version + run: | + set -euo pipefail + python - <<'PY' + import os + import re + from pathlib import Path + + version = os.environ["RELEASE_VERSION"] + notes_path = Path("PYPI-RELEASE-NOTES.md") + heading = re.compile(r"^# PHART (\S+)", re.MULTILINE) + body = None + + if notes_path.exists(): + text = notes_path.read_text(encoding="utf-8") + matches = list(heading.finditer(text)) + for index, match in enumerate(matches): + if match.group(1) != version: + continue + start = text.index("\n", match.start()) + 1 + end = ( + matches[index + 1].start() + if index + 1 < len(matches) + else len(text) + ) + body = text[start:end].strip() + break + + if not body: + body = ( + f"PHART {version}. No section for this version was found in " + "PYPI-RELEASE-NOTES.md." + ) + print(f"::warning::{body}") + + Path("release-notes.md").write_text(body + "\n", encoding="utf-8") + print(body) + PY + + - name: Create tag and GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + gh release create "v${RELEASE_VERSION}" \ + --repo "${GITHUB_REPOSITORY}" \ + --target "${GITHUB_SHA}" \ + --title "PHART ${RELEASE_VERSION}" \ + --notes-file release-notes.md \ + dist/* + echo "Created tag v${RELEASE_VERSION} and GitHub Release at ${GITHUB_SHA}" diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index b72b2b3..e99c21f 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -6,6 +6,10 @@ on: pull_request: branches: [main] workflow_call: + schedule: + # Weekly drift check. Catches dependency/stub updates that break CI + # between pushes, so they surface before release day instead of on it. + - cron: "0 9 * * 1" jobs: test: