From de9d7755421613883c8dafcf692fcb3a7095a3b1 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 00:39:13 -0600 Subject: [PATCH 01/48] ci: Add script to compute the next release version * Add .github/scripts/next_version.py to compute the next release version with 'packaging' from the Git tags reachable from HEAD, replacing the manual semver arithmetic previously done in Bash in the bump version GitHub Actions workflow. - As only reachable tags are considered, versions are computed relative to the release series of the branch being released from (e.g. patch releases from a release/vX.Y.x branch). - Comparing versions under PEP 440 ordering avoids relying on the lexicographic ordering of 'git tag' output. - Guard against releasing from an unintended branch by requiring the computed version to be newer than the current version in tbump.toml. * Add tests validating the version computation transitions for stable releases and release candidates. Assisted-by: ClaudeCode:claude-fable-5 --- .github/scripts/next_version.py | 85 +++++++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_next_version.py | 45 +++++++++++++++++ 3 files changed, 131 insertions(+) create mode 100644 .github/scripts/next_version.py create mode 100644 tests/test_next_version.py diff --git a/.github/scripts/next_version.py b/.github/scripts/next_version.py new file mode 100644 index 0000000000..356197309f --- /dev/null +++ b/.github/scripts/next_version.py @@ -0,0 +1,85 @@ +"""Compute the next release version from the Git tags reachable from HEAD.""" + +import argparse +import subprocess +from pathlib import Path + +import tomllib +from packaging.version import Version + + +def next_version(part, release_candidate, latest, latest_stable): + """ + Compute the next release version. + + Stable releases bump ``part`` relative to the latest stable release, so a + stable release after a release candidate series finalizes it (e.g. latest + tag v0.8.0rc2 with a minor bump gives v0.8.0). Release candidates increment + the candidate number if the latest tag is already a candidate for the + target version, and start at rc1 otherwise. + + Args: + part (str): The semantic version part to bump: major, minor, or patch. + release_candidate (bool): If the next version is a release candidate. + latest (packaging.version.Version): The latest release, stable or not. + latest_stable (packaging.version.Version): The latest stable release. + + Returns: + str: The next version, without a leading "v". + """ + major, minor, patch = latest_stable.release + target = { + "major": (major + 1, 0, 0), + "minor": (major, minor + 1, 0), + "patch": (major, minor, patch + 1), + }[part] + if not release_candidate: + return "{}.{}.{}".format(*target) + if latest.is_prerelease and latest.release == target: + return "{}.{}.{}rc{}".format(*target, latest.pre[1] + 1) + return "{}.{}.{}rc1".format(*target) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--part", choices=["major", "minor", "patch"], required=True) + parser.add_argument( + "--rc", + choices=["true", "false"], + default="false", + help="if the next version is a release candidate", + ) + args = parser.parse_args() + + # Only tags reachable from HEAD, so releases from a release/vX.Y.x branch + # are computed relative to that release series and not the default branch. + tags = subprocess.run( + ["git", "tag", "--list", "v*", "--merged", "HEAD"], + check=True, + capture_output=True, + text=True, + ).stdout.split() + versions = [Version(tag.removeprefix("v")) for tag in tags] + latest = max(versions) + latest_stable = max(version for version in versions if not version.is_prerelease) + version = next_version(args.part, args.rc == "true", latest, latest_stable) + + # The patch release tags of a release series are only reachable from its + # release/vX.Y.x branch, so guard against computing a version bump from a + # branch that is not part of the intended release series (e.g. a patch + # release of an old series attempted from the default branch). + with Path("tbump.toml").open("rb") as manifest: + current = Version(tomllib.load(manifest)["version"]["current"]) + if Version(version) <= current: + error_message = ( + f"ERROR: computed next version {version} is not newer than the" + f" current version {current} in tbump.toml." + " Is this the correct branch for this release?" + ) + raise SystemExit(error_message) + + print(version) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index ebd5620b34..452130dde0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -314,6 +314,7 @@ typing-modules = ["pyhf.typing"] flake8-tidy-imports.ban-relative-imports = "all" [tool.ruff.lint.per-file-ignores] +".github/scripts/**" = ["T20"] "docs/jupyterlite/jupyterlite.py" = ["F401", "F704"] "**.ipynb" = ["T20"] "noxfile.py" = ["T20"] diff --git a/tests/test_next_version.py b/tests/test_next_version.py new file mode 100644 index 0000000000..d43fb53597 --- /dev/null +++ b/tests/test_next_version.py @@ -0,0 +1,45 @@ +import importlib.util +from pathlib import Path + +import pytest + +packaging_version = pytest.importorskip("packaging.version") + +_spec = importlib.util.spec_from_file_location( + "next_version", + Path(__file__).parent.parent / ".github" / "scripts" / "next_version.py", +) +_module = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_module) +next_version = _module.next_version + + +@pytest.mark.parametrize( + ("part", "release_candidate", "latest", "latest_stable", "expected"), + [ + ("patch", False, "0.7.6", "0.7.6", "0.7.7"), + ("minor", False, "0.7.6", "0.7.6", "0.8.0"), + ("major", False, "0.7.6", "0.7.6", "1.0.0"), + # first release candidate of a series + ("patch", True, "0.7.6", "0.7.6", "0.7.7rc1"), + ("minor", True, "0.7.6", "0.7.6", "0.8.0rc1"), + ("major", True, "0.7.6", "0.7.6", "1.0.0rc1"), + # increment the release candidate of the same target version + ("minor", True, "0.8.0rc1", "0.7.6", "0.8.0rc2"), + ("major", True, "1.0.0rc3", "0.7.6", "1.0.0rc4"), + # a release candidate for a different target starts a new series + ("patch", True, "0.8.0rc1", "0.7.6", "0.7.7rc1"), + # a stable release finalizes a release candidate series + ("minor", False, "0.8.0rc2", "0.7.6", "0.8.0"), + ], +) +def test_next_version(part, release_candidate, latest, latest_stable, expected): + assert ( + next_version( + part, + release_candidate, + packaging_version.Version(latest), + packaging_version.Version(latest_stable), + ) + == expected + ) From 07542e16fa0b21a4848c1af867cbff5bbef5a4e4 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 00:39:27 -0600 Subject: [PATCH 02/48] ci: Add Prepare release GitHub Actions workflow * Add a workflow dispatch triggered workflow that computes the next release version for the selected semver part (with a release candidate option), bumps the version of all files defined in tbump.toml with 'tbump --only-patch', and opens a release preparation pull request with the changes. * The release preparation pull request serves as the release dry run: it is reviewed under the normal branch protections and CI validates the bumped files before it is merged, removing the need to push release commits directly to protected branches. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/release-prepare.yml diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000000..9fdeb0d375 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,78 @@ +name: Prepare release + +on: + workflow_dispatch: + inputs: + part: + description: 'Semver part of the release (major | minor | patch)' + required: true + type: choice + options: + - patch + - minor + - major + release_candidate: + type: boolean + description: 'Release candidate' + default: false + +permissions: + contents: read + +jobs: + prepare: + name: Open release preparation pull request + runs-on: ubuntu-latest + if: github.repository == 'scikit-hep/pyhf' + + steps: + # The workflow dispatch ref selects the branch to release from + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v7 + with: + python-version: '3.14' + + - name: Install Python dependencies + run: | + python -m pip install uv + uv pip install --system packaging tbump + python -m pip list + + - name: Compute next version + id: version + env: + PART: ${{ inputs.part }} + RELEASE_CANDIDATE: ${{ inputs.release_candidate }} + run: | + version="$(python .github/scripts/next_version.py --part "${PART}" --rc "${RELEASE_CANDIDATE}")" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "## Computed next version: ${version}" >> "$GITHUB_STEP_SUMMARY" + + - name: Bump version in files + env: + VERSION: ${{ steps.version.outputs.version }} + run: tbump --non-interactive --only-patch "${VERSION}" + + # Use GitHub PAT to authenticate so CI triggers on the pull request + - name: Open release preparation pull request + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 + with: + token: ${{ secrets.ACCESS_TOKEN }} + commit-message: 'chore: Bump version to v${{ steps.version.outputs.version }}' + title: 'chore: Bump version to v${{ steps.version.outputs.version }}' + branch: bump-version/v${{ steps.version.outputs.version }} + base: ${{ github.ref_name }} + delete-branch: true + body: | + Bump version from the latest release to `v${{ steps.version.outputs.version }}` in all files managed by `tbump.toml`. + + Reviewing this pull request is the release "dry run": + * [ ] Verify the computed version is the intended release version. + * [ ] Verify there is a release notes file for the release under `docs/release-notes`. + * [ ] After merging, run the [Tag release workflow](https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml) on this branch to create the release tag. From ca52ae02d7593c65ca7eaeb381f4d34f27fafced Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 00:39:42 -0600 Subject: [PATCH 03/48] ci: Add Tag release GitHub Actions workflow * Add a workflow dispatch triggered workflow that creates an annotated release tag for the version defined in tbump.toml by the merged release preparation pull request, so the tag can not disagree with the bumped files, and pushes it to the release branch to trigger the deployment to TestPyPI. * The tag annotation summarizes the conventional commit changes since the previous release. * The workflow is gated by the 'release-tag' GitHub Actions environment, whose required reviewers and deployment branch policy (main, release/v*) replace the previous hardcoded maintainer allowlist and release branch checks. * The tag is pushed with a GitHub PAT so that the tag push triggers the publishing and Docker workflows. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-tag.yml | 69 +++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 .github/workflows/release-tag.yml diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 0000000000..eea543351a --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,69 @@ +name: Tag release + +# No inputs: the version tagged is the one in tbump.toml on the dispatched +# branch, set by the merged release preparation pull request, so the tag can +# never disagree with the bumped files. +on: + workflow_dispatch: + +permissions: + contents: read + +jobs: + tag: + name: Tag release + runs-on: ubuntu-latest + if: github.repository == 'scikit-hep/pyhf' + # Required reviewers and the allowed branches (main, release/v*) are + # enforced through the environment's protection rules + environment: + name: release-tag + + steps: + # Use GitHub PAT to authenticate so the tag push triggers the publishing + # and Docker workflows + - name: Checkout code + uses: actions/checkout@v7 + with: + fetch-depth: 0 + token: ${{ secrets.ACCESS_TOKEN }} + + - name: Read version from tbump.toml + id: version + run: | + version="$(python3 -c 'import tomllib; print(tomllib.load(open("tbump.toml", "rb"))["version"]["current"])')" + echo "version=${version}" >> "$GITHUB_OUTPUT" + + - name: Create annotated tag with changelog + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if git rev-parse --quiet --verify "refs/tags/v${VERSION}" > /dev/null; then + echo "ERROR: tag v${VERSION} already exists. Merge a release preparation pull request first." >&2 + exit 1 + fi + + if [[ "${VERSION}" == *rc* ]]; then + previous_tag="$(git describe --tags --abbrev=0)" + else + # Stable releases list the changes since the previous stable + # release, spanning any release candidate series + previous_tag="$(git tag --list 'v*' --merged HEAD --sort=v:refname | grep --invert-match rc | tail -n 1)" + fi + + changes="$(git log --pretty=format:' - %s' "${previous_tag}"..HEAD \ + --regexp-ignore-case --extended-regexp \ + --grep='^[a-z]+(\([a-z0-9._-]+\))?!?:' \ + --grep='\(backport\):')" + + git config --local user.name "github-actions[bot]" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git tag --annotate "v${VERSION}" \ + --message "$(printf 'pyhf v%s\n\nChanges from %s:\n%s' "${VERSION}" "${previous_tag}" "${changes}")" + + git tag -n99 --list "v${VERSION}" + + - name: Push tag to GitHub + env: + VERSION: ${{ steps.version.outputs.version }} + run: git push origin "v${VERSION}" From 68f945025a9af72b9803d483a59282577aa88268 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 00:39:42 -0600 Subject: [PATCH 04/48] ci: Remove bump version GitHub Actions workflow * Remove the bump version workflow, superseded by the Prepare release and Tag release workflows, along with its manual semver validation Bash and its direct pushes of release commits to protected branches. * The removed Bash contained latent bugs that motivated the rewrite: - The dry run guard "if: ${{ github.event.inputs.dry_run }} == 'false'" interpolates to an always truthy string, so the guarded tag annotation step ran on every dry run. - "git tag | grep --invert-match rc | tail -n 1" relies on lexicographic ordering, which misidentifies the latest stable tag for two digit version components (e.g. v0.10.0 sorts before v0.2.0). Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/bump-version.yml | 288 ----------------------------- 1 file changed, 288 deletions(-) delete mode 100644 .github/workflows/bump-version.yml diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml deleted file mode 100644 index 952c5f60dc..0000000000 --- a/.github/workflows/bump-version.yml +++ /dev/null @@ -1,288 +0,0 @@ -name: Bump version - -on: - workflow_dispatch: - inputs: - part: - description: 'Semver type of new version (major | minor | patch)' - required: true - type: choice - options: - - patch - - minor - - major - release_candidate: - type: boolean - description: 'Release candidate' - default: false - new_version: - description: 'New version to bump to' - required: true - type: string - target_branch: - description: 'Branch to push tag to' - default: 'main' - required: true - type: string - force: - type: boolean - description: 'Force override check' - default: false - dry_run: - type: boolean - description: 'Perform a dry run to check' - default: true - -permissions: - contents: read - -jobs: - bump-version: - permissions: - contents: write # for Git to git push - runs-on: ubuntu-latest - environment: - name: ci - deployment: false - if: github.repository == 'scikit-hep/pyhf' - - steps: - # Use GitHub PAT to authenticate so other workflows trigger - - name: Checkout code - uses: actions/checkout@v7 - with: - ref: ${{ github.event.inputs.target_branch }} - fetch-depth: 0 - token: ${{ secrets.ACCESS_TOKEN }} - persist-credentials: false - - - name: Check target branch is intended for release - if: github.event.inputs.force == 'false' - shell: bash - run: | - git rev-parse --abbrev-ref HEAD | grep 'main\|release/' - if [ $? -eq 1 ]; then - echo "ERROR: Branch $(git rev-parse --abbrev-ref HEAD) is not intended for release." - echo " Releases are made only from main or release branches." - exit 1 - fi - - - name: Verify new version bump step is valid - if: github.event.inputs.force == 'false' - id: script - shell: bash - run: | - current_tag="$(git describe --tags --abbrev=0)" - current_tag="${current_tag:1}" - - latest_stable_tag="$(git tag | grep --invert-match 'rc' | tail -n 1)" - latest_stable_tag="${latest_stable_tag:1}" - - echo "* Current version: ${current_tag}" - echo "* Latest stable version: ${latest_stable_tag}" - - if [ ${GITHUB_EVENT_INPUTS_RELEASE_CANDIDATE} == 'true' ]; then - echo "* Attempting a ${GITHUB_EVENT_INPUTS_PART} version release candidate bump from ${current_tag} to: ${GITHUB_EVENT_INPUTS_NEW_VERSION}" - else - # For ease of use, set current tag to latest stable - current_tag="${latest_stable_tag}" - - echo "* Attempting a ${GITHUB_EVENT_INPUTS_PART} version bump from ${current_tag} to: ${GITHUB_EVENT_INPUTS_NEW_VERSION}" - fi - - echo "* Validating bump target version matches SemVer..." - - # IFS is single charecter, so split on the 'r' in "rc" - IFS='r' read current_tag_read current_rc <> $GITHUB_OUTPUT - env: - GITHUB_EVENT_INPUTS_RELEASE_CANDIDATE: ${{ github.event.inputs.release_candidate }} - GITHUB_EVENT_INPUTS_PART: ${{ github.event.inputs.part }} - GITHUB_EVENT_INPUTS_NEW_VERSION: ${{ github.event.inputs.new_version }} - - - name: Set up Python - if: success() - uses: actions/setup-python@v7 - with: - python-version: '3.14' - - - name: Install Python dependencies - run: | - python -m pip install uv - uv pip install --system tbump - python -m pip list - - - name: Setup Git user to push new tag - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - - name: Bump version and push to GitHub - if: >- - github.event_name == 'workflow_dispatch' - && ( - github.event.sender.login == 'lukasheinrich' || - github.event.sender.login == 'matthewfeickert' || - github.event.sender.login == 'kratsg' - ) - shell: bash - run: | - tbump --non-interactive --no-push ${GITHUB_EVENT_INPUTS_NEW_VERSION} - env: - GITHUB_EVENT_INPUTS_NEW_VERSION: ${{ github.event.inputs.new_version }} - - - name: Update the Git tag annotation - if: ${{ github.event.inputs.dry_run == 'false' }} - shell: bash - run: | - OLD_TAG=${STEPS_SCRIPT_OUTPUTS_OLD_TAG} - git tag -n99 --list "${OLD_TAG}" - - NEW_TAG=v${GITHUB_EVENT_INPUTS_NEW_VERSION} - git tag -n99 --list "${NEW_TAG}" - - CHANGES=$(git log --pretty=format:'%s' "${OLD_TAG}"..HEAD --regexp-ignore-case --extended-regexp --grep='^([a-z]*?):') - # Also include any backported changes - BACKPORTED_CHANGES=$(git log --pretty=format:'%s' "${OLD_TAG}"..HEAD --grep='(backport):') - if [ ! -z "${BACKPORTED_CHANGES}" ]; then - CHANGES=$(printf "${CHANGES}\n${BACKPORTED_CHANGES}") - fi - - CHANGES_NEWLINE="$(echo "${CHANGES}" | sed -e 's/^/ - /')" - SANITIZED_CHANGES=$(echo "${CHANGES}" | sed -e 's/^/
  • /' -e 's|$|
  • |' -e 's/(#[0-9]\+)//' -e 's/"/'"'"'/g') - NUM_CHANGES=$(echo -n "${CHANGES}" | grep -c '^') - - if [ ${GITHUB_EVENT_INPUTS_RELEASE_CANDIDATE} == 'true' ]; then - git tag "${NEW_TAG}" "${NEW_TAG}"^{} -f -m "$(printf "This is a ${GITHUB_EVENT_INPUTS_PART} release candidate from ${OLD_TAG} → ${NEW_TAG}.\n\nChanges:\n${CHANGES_NEWLINE}")" - else - git tag "${NEW_TAG}" "${NEW_TAG}"^{} -f -m "$(printf "This is a ${GITHUB_EVENT_INPUTS_PART} release from ${OLD_TAG} → ${NEW_TAG}.\n\nChanges:\n${CHANGES_NEWLINE}")" - fi - - git tag -n99 --list "${NEW_TAG}" - env: - STEPS_SCRIPT_OUTPUTS_OLD_TAG: ${{ steps.script.outputs.old_tag }} - GITHUB_EVENT_INPUTS_NEW_VERSION: ${{ github.event.inputs.new_version }} - GITHUB_EVENT_INPUTS_RELEASE_CANDIDATE: ${{ github.event.inputs.release_candidate }} - GITHUB_EVENT_INPUTS_PART: ${{ github.event.inputs.part }} - - - name: Show annotated Git tag - shell: bash - run: | - git show v${GITHUB_EVENT_INPUTS_NEW_VERSION} - env: - GITHUB_EVENT_INPUTS_NEW_VERSION: ${{ github.event.inputs.new_version }} - - - name: Push new tag back to GitHub - shell: bash - run: | - if [ ${GITHUB_EVENT_INPUTS_DRY_RUN} == 'true' ]; then - echo "# DRY RUN" - else - git push origin ${GITHUB_EVENT_INPUTS_TARGET_BRANCH} --tags - fi - env: - GITHUB_EVENT_INPUTS_DRY_RUN: ${{ github.event.inputs.dry_run }} - GITHUB_EVENT_INPUTS_TARGET_BRANCH: ${{ github.event.inputs.target_branch }} From b93ebf2cd4f8b2c78b24fe3c6b49beaea58f9fc4 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 00:39:59 -0600 Subject: [PATCH 05/48] ci: Simplify publish distributions workflow * Use the hynek/build-and-inspect-python-package GitHub Action to build the sdist and wheel, replacing the manual build steps, as recommended by the Scientific Python development guide. - twine checks and listings of the sdist and wheel contents are provided in the workflow run summary. * Define the publishing conditions once in a 'Determine publish target' gate step, replacing the five duplicated multi-line step conditions. * Split the publish job into separate TestPyPI and PyPI jobs selected by the gate step output. The trusted publisher configuration is unchanged, as the workflow file name and GitHub Actions environment name are preserved. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 149 ++++++++++++++------------ 1 file changed, 81 insertions(+), 68 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 711ff6e563..b744c36913 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -35,6 +35,8 @@ jobs: permissions: id-token: write attestations: write + outputs: + publish-target: ${{ steps.gate.outputs.target }} steps: - uses: actions/checkout@v7 @@ -42,41 +44,48 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: '3.14' - - - name: Install python-build and twine + # The single definition of when the built distribution gets published: + # a Git tag push deploys to TestPyPI for verification in advance of the + # release, a GitHub release publication deploys to PyPI, and a manual + # workflow dispatch can deploy a dev release snapshot to TestPyPI + - name: Determine publish target + id: gate + if: github.repository == 'scikit-hep/pyhf' + env: + PUBLISH_INPUT: ${{ inputs.publish }} run: | - python -m pip install uv - uv pip install --system --upgrade pip - uv pip install --system build twine - python -m pip list + target="" + if [ "${GITHUB_EVENT_NAME}" == "release" ]; then + target="pypi" + elif [ "${GITHUB_EVENT_NAME}" == "push" ] && [[ "${GITHUB_REF}" == refs/tags/v* ]]; then + target="testpypi" + elif [ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ] && [ "${PUBLISH_INPUT}" == "true" ]; then + target="testpypi" + fi + echo "target=${target}" >> "$GITHUB_OUTPUT" - name: Build a sdist and wheel - if: github.event_name != 'schedule' - run: | - python -m build --installer uv . - - - name: Build a sdist and wheel and check for warnings - if: github.event_name == 'schedule' - run: | - PYTHONWARNINGS=error,default::DeprecationWarning python -m build --installer uv . + id: baipp + uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 + env: + # Weekly check that builds are warning free + PYTHONWARNINGS: ${{ github.event_name == 'schedule' && 'error,default::DeprecationWarning' || '' }} - name: Verify untagged commits have dev versions if: "!startsWith(github.ref, 'refs/tags/')" + env: + DIST_PATH: ${{ steps.baipp.outputs.dist }} run: | latest_tag=$(git describe --tags) - latest_tag_revlist_SHA=$(git rev-list -n 1 ${latest_tag}) + latest_tag_revlist_SHA=$(git rev-list -n 1 "${latest_tag}") main_SHA="$(git rev-parse --verify origin/main)" - wheel_name=$(find dist/ -iname "*.whl" -printf "%f\n") + wheel_name=$(find "${DIST_PATH}" -iname "*.whl" -printf "%f\n") if [[ "${latest_tag_revlist_SHA}" != "${main_SHA}" ]]; then # don't check main push events coming from tags if [[ "${wheel_name}" == *"pyhf-0.1.dev"* || "${wheel_name}" != *"dev"* ]]; then echo "python-build incorrectly named built distribution: ${wheel_name}" echo "python-build is lacking the history and tags required to determine version number" - echo "intentionally erroring with 'return 1' now" - return 1 + echo "intentionally erroring with 'exit 1' now" + exit 1 fi else echo "Push event to origin/main was triggered by push of tag ${latest_tag}" @@ -85,45 +94,28 @@ jobs: - name: Verify tagged commits don't have dev versions if: startsWith(github.ref, 'refs/tags') + env: + DIST_PATH: ${{ steps.baipp.outputs.dist }} run: | - wheel_name=$(find dist/ -iname "*.whl" -printf "%f\n") + wheel_name=$(find "${DIST_PATH}" -iname "*.whl" -printf "%f\n") if [[ "${wheel_name}" == *"dev"* ]]; then echo "python-build incorrectly named built distribution: ${wheel_name}" echo "this is incorrrectly being treated as a dev release" - echo "intentionally erroring with 'return 1' now" - return 1 + echo "intentionally erroring with 'exit 1' now" + exit 1 fi echo "python-build named built distribution: ${wheel_name}" - - name: Verify the distribution - run: twine check --strict dist/* - - - name: List contents of sdist - run: python -m tarfile --list dist/pyhf-*.tar.gz - - - name: List contents of wheel - run: python -m zipfile --list dist/pyhf-*.whl - - name: Generate artifact attestation for sdist and wheel - # If publishing to TestPyPI or PyPI - if: >- - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'scikit-hep/pyhf') - || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' && github.repository == 'scikit-hep/pyhf') - || (github.event_name == 'release' && github.event.action == 'published' && github.repository == 'scikit-hep/pyhf') + if: steps.gate.outputs.target != '' uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: - subject-path: "dist/pyhf-*" - - - name: Upload distribution artifact - uses: actions/upload-artifact@v7.0.1 - with: - name: dist-artifact - path: dist + subject-path: "${{ steps.baipp.outputs.dist }}/pyhf-*" - publish: - name: Publish Python distribution to (Test)PyPI - if: github.event_name != 'pull_request' + publish-testpypi: + name: Publish Python distribution to TestPyPI needs: build + if: needs.build.outputs.publish-target == 'testpypi' runs-on: ubuntu-latest # Mandatory for publishing with a trusted publisher # c.f. https://docs.pypi.org/trusted-publishers/using-a-publisher/ @@ -137,45 +129,66 @@ jobs: - name: Download distribution artifact uses: actions/download-artifact@v8 with: - name: dist-artifact + name: Packages path: dist - name: List all files run: ls -lh dist - name: Verify sdist artifact attestation - # If publishing to TestPyPI or PyPI - if: >- - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'scikit-hep/pyhf') - || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' && github.repository == 'scikit-hep/pyhf') - || (github.event_name == 'release' && github.event.action == 'published' && github.repository == 'scikit-hep/pyhf') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh attestation verify dist/pyhf-*.tar.gz --repo ${{ github.repository }} + REPOSITORY: ${{ github.repository }} + run: gh attestation verify dist/pyhf-*.tar.gz --repo "${REPOSITORY}" - name: Verify wheel artifact attestation - # If publishing to TestPyPI or PyPI - if: >- - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'scikit-hep/pyhf') - || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' && github.repository == 'scikit-hep/pyhf') - || (github.event_name == 'release' && github.event.action == 'published' && github.repository == 'scikit-hep/pyhf') env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh attestation verify dist/pyhf-*.whl --repo ${{ github.repository }} + REPOSITORY: ${{ github.repository }} + run: gh attestation verify dist/pyhf-*.whl --repo "${REPOSITORY}" - name: Publish distribution 📦 to Test PyPI - # Publish to TestPyPI on tag events of if manually triggered - # Compare to 'true' string as booleans get turned into strings in the console - if: >- - (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'scikit-hep/pyhf') - || (github.event_name == 'workflow_dispatch' && github.event.inputs.publish == 'true' && github.repository == 'scikit-hep/pyhf') uses: pypa/gh-action-pypi-publish@v1.14.2 with: repository-url: https://test.pypi.org/legacy/ print-hash: true + publish-pypi: + name: Publish Python distribution to PyPI + needs: build + if: needs.build.outputs.publish-target == 'pypi' + runs-on: ubuntu-latest + # Mandatory for publishing with a trusted publisher + # c.f. https://docs.pypi.org/trusted-publishers/using-a-publisher/ + permissions: + id-token: write + # Restrict to the environment set for the trusted publisher + environment: + name: publish-package + + steps: + - name: Download distribution artifact + uses: actions/download-artifact@v8 + with: + name: Packages + path: dist + + - name: List all files + run: ls -lh dist + + - name: Verify sdist artifact attestation + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: gh attestation verify dist/pyhf-*.tar.gz --repo "${REPOSITORY}" + + - name: Verify wheel artifact attestation + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPOSITORY: ${{ github.repository }} + run: gh attestation verify dist/pyhf-*.whl --repo "${REPOSITORY}" + - name: Publish distribution 📦 to PyPI - if: github.event_name == 'release' && github.event.action == 'published' && github.repository == 'scikit-hep/pyhf' uses: pypa/gh-action-pypi-publish@v1.14.2 with: print-hash: true From 0e474f9101aafd3aacca25355b00b57355ab9024 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 00:40:00 -0600 Subject: [PATCH 06/48] docs: Update release process docs for the release workflows * Update the Publishing section of the development docs to describe preparing a release with the Prepare release workflow and tagging it with the Tag release workflow. * Document the local fallback release procedure for release branches that predate the release workflows. * Update the release checklist issue template for the new release process. Assisted-by: ClaudeCode:claude-fable-5 --- .github/ISSUE_TEMPLATE/~release-checklist.md | 14 ++--- docs/development.rst | 66 +++++++++++++------- 2 files changed, 51 insertions(+), 29 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/~release-checklist.md b/.github/ISSUE_TEMPLATE/~release-checklist.md index d10f2fbd25..79755d269c 100644 --- a/.github/ISSUE_TEMPLATE/~release-checklist.md +++ b/.github/ISSUE_TEMPLATE/~release-checklist.md @@ -23,20 +23,18 @@ about: Checklist for core developers to complete as part of making a release ## Create Release Tag -For a video walkthrough consult the [``pyhf`` ``v0.7.1`` release recording](https://youtu.be/ZV20tr3EpTw) on YouTube. - -* [ ] Use the [bump version](https://github.com/scikit-hep/pyhf/actions/workflows/bump-version.yml) GitHub Actions workflow perform a [dry run](https://scikit-hep.org/pyhf/development.html#release-tags) of the bump version to the new release tag. -* [ ] Check the annotated tag in the dry run workflow logs to make sure it looks correct. -* [ ] If the dry run passes as expected, run the same workflow with the dry run option set to ``false`` to bump the release tag version and push the new tag back to GitHub. -* [ ] Verify the release tag was pushed to the correct branch. -* [ ] Verify the release tag commit has bumped the correct versions. +* [ ] Run the [Prepare release](https://github.com/scikit-hep/pyhf/actions/workflows/release-prepare.yml) GitHub Actions workflow on the release branch, selecting the SemVer part of the release and if the release is a release candidate. +* [ ] Review the release preparation pull request the workflow opens: verify the computed version and the diff of the bumped files, and wait for CI to pass. +* [ ] Merge the release preparation pull request. +* [ ] Run the [Tag release](https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml) GitHub Actions workflow on the release branch and approve the `release-tag` environment deployment. +* [ ] Verify the release tag was pushed to the correct branch and that its annotation summarizes the changes since the previous release. * [ ] Watch the CI to verify all tag based jobs finish correctly. * [ ] Verify the release for the tag on [TestPyPI][TestPyPI_pyhf] looks correct. ## After Release Tag Pushed To GitHub * [ ] Create a [GitHub release](https://github.com/scikit-hep/pyhf/releases) from the new release tag and copy the release notes published to the GitHub release page. The creation of the GitHub release triggers all other release related activities. - - [ ] Before pasting in the release notes copy the changes that the GitHub bot has already queued up and pasted into the tag and place them in the "Changes" section of the release notes. If the release notes are published before these are copied then they will be overwritten and you'll have to add them back in by hand. + - [ ] Copy the changes from the release tag's annotation into the "Changes" section of the release notes. - [ ] Create a corresponding [announcement GitHub Discussion](https://github.com/scikit-hep/pyhf/discussions/categories/announcements) for the release. * [ ] Watch the CI to ensure that the deployment to [PyPI](https://pypi.org/project/pyhf/) is successful. * [ ] Verify Docker images with the correct tags have been deployed to all container image registries. diff --git a/docs/development.rst b/docs/development.rst index 9cf3cee2f3..af9163d9a7 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -180,8 +180,7 @@ Publishing Publishing to TestPyPI_ and PyPI_ is automated through the `PyPA's PyPI publish GitHub Action `__ -and the ``pyhf`` `bump version GitHub Actions workflow -`__. +and the ``pyhf`` `Prepare release`_ and `Tag release`_ GitHub Actions workflows. Release Checklist ~~~~~~~~~~~~~~~~~ @@ -191,27 +190,51 @@ sure steps aren't missed. There is a GitHub Issue template for this that the maintainer in charge of the release should step through and update if needed. -Release Tags -~~~~~~~~~~~~ +Preparing a Release +~~~~~~~~~~~~~~~~~~~ -A release tag can be created by a maintainer by using the `bump version GitHub Actions -workflow`_ through workflow dispatch. +A release is prepared by a maintainer running the `Prepare release`_ GitHub Actions +workflow through workflow dispatch. The maintainer needs to: -* Select the semantic versioning (SemVer) type (major, minor, patch) of the release tag. -* Select if the release tag is a release candidate or not. -* Input the SemVer version number of the release tag. -* Select the branch to push the new release tag to. -* Select if to override the SemVer compatibility of the previous options (default - is to run checks). -* Select if a dry run should be performed (default is to do a dry run to avoid accidental - release tags). - -The maintainer **should do a dry run first to make sure everything looks reasonable**. -Once they have done that, they can run the `bump version GitHub Actions workflow`_ which -will produce a new tag, bump the version of all files defined in `tbump.toml -`__, and then commit and -push these changes and the tag back to the ``main`` branch. +* Select the branch to release from (``main`` or a ``release/vX.Y.x`` release branch). +* Select the semantic versioning (SemVer) part of the release (major, minor, patch). +* Select if the release is a release candidate or not. + +The workflow computes the next release version from the Git tags reachable from the +selected branch and opens a release preparation pull request that bumps the version +of all files defined in `tbump.toml +`__ to it. +The pull request serves as the release dry run: the maintainer should verify the +computed version and the diff of the bumped files, and let CI validate the changes, +before merging. + +Tagging a Release +~~~~~~~~~~~~~~~~~ + +After the release preparation pull request has been merged, a maintainer runs the +`Tag release`_ GitHub Actions workflow through workflow dispatch on the release +branch. +The workflow requires approval through the ``release-tag`` GitHub Actions +environment, and then creates an annotated tag for the version defined in +``tbump.toml`` — with an annotation summarizing the changes since the previous +release — and pushes the tag to the release branch. + +If the release workflows are not available on the release branch (e.g. historic +release branches) a maintainer can perform the same steps locally by bumping the +version of the files + +.. code-block:: console + + tbump --non-interactive --only-patch X.Y.Z + +and, after the pull request with these changes has been merged into the release +branch, creating and pushing the release tag + +.. code-block:: console + + git tag --annotate vX.Y.Z --message "pyhf vX.Y.Z" + git push origin vX.Y.Z Deployment ~~~~~~~~~~ @@ -272,6 +295,7 @@ The ``.zenodo.json`` file has the version number automatically updated through ``tbump``, though its additional metadata should be checked periodically by the dev team (probably every release). -.. _bump version GitHub Actions workflow: https://github.com/scikit-hep/pyhf/actions/workflows/bump-version.yml +.. _Prepare release: https://github.com/scikit-hep/pyhf/actions/workflows/release-prepare.yml +.. _Tag release: https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml .. _PyPI: https://pypi.org/project/pyhf/ .. _TestPyPI: https://test.pypi.org/project/pyhf/ From 2b3a246eaef4e6410e87bb3163941204db995f24 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 01:13:22 -0600 Subject: [PATCH 07/48] ci: Defer tomllib import in next version script * Import tomllib inside main() as tomllib requires Python >= 3.11 and the tests import next_version() from the script on all supported Python versions, which caused test collection to fail with a ModuleNotFoundError on Python < 3.11. * The workflows that run the script's command line interface use Python 3.14, where tomllib is available. Assisted-by: ClaudeCode:claude-fable-5 --- .github/scripts/next_version.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/scripts/next_version.py b/.github/scripts/next_version.py index 356197309f..dbe3331286 100644 --- a/.github/scripts/next_version.py +++ b/.github/scripts/next_version.py @@ -4,7 +4,6 @@ import subprocess from pathlib import Path -import tomllib from packaging.version import Version @@ -41,6 +40,10 @@ def next_version(part, release_candidate, latest, latest_stable): def main(): + # tomllib requires Python >= 3.11, which the workflows running this + # script provide, but the tests importing next_version() might not + import tomllib + parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--part", choices=["major", "minor", "patch"], required=True) parser.add_argument( From a7eed66489b91b6f36983df5ee829e6d1ab39c1f Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 08:45:48 -0600 Subject: [PATCH 08/48] ci: Use GitHub CLI for release preparation pull requests * Replace the peter-evans/create-pull-request GitHub Action with 'git' and 'gh pr create', which are preinstalled on the GitHub Actions runners and maintained by GitHub, to avoid depending on an individually maintained third-party GitHub Action in the release process. * Replace the semver part and release candidate workflow inputs and the next version computation script with a directly input version. The version is validated inline to be newer than the current version in tbump.toml, tbump validates the version format, and the review of the release preparation pull request validates the release version. * Remove the now unused next version script and its tests. Assisted-by: ClaudeCode:claude-fable-5 --- .github/scripts/next_version.py | 88 ------------------------ .github/workflows/release-prepare.yml | 96 ++++++++++++++++----------- pyproject.toml | 1 - tests/test_next_version.py | 45 ------------- 4 files changed, 59 insertions(+), 171 deletions(-) delete mode 100644 .github/scripts/next_version.py delete mode 100644 tests/test_next_version.py diff --git a/.github/scripts/next_version.py b/.github/scripts/next_version.py deleted file mode 100644 index dbe3331286..0000000000 --- a/.github/scripts/next_version.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Compute the next release version from the Git tags reachable from HEAD.""" - -import argparse -import subprocess -from pathlib import Path - -from packaging.version import Version - - -def next_version(part, release_candidate, latest, latest_stable): - """ - Compute the next release version. - - Stable releases bump ``part`` relative to the latest stable release, so a - stable release after a release candidate series finalizes it (e.g. latest - tag v0.8.0rc2 with a minor bump gives v0.8.0). Release candidates increment - the candidate number if the latest tag is already a candidate for the - target version, and start at rc1 otherwise. - - Args: - part (str): The semantic version part to bump: major, minor, or patch. - release_candidate (bool): If the next version is a release candidate. - latest (packaging.version.Version): The latest release, stable or not. - latest_stable (packaging.version.Version): The latest stable release. - - Returns: - str: The next version, without a leading "v". - """ - major, minor, patch = latest_stable.release - target = { - "major": (major + 1, 0, 0), - "minor": (major, minor + 1, 0), - "patch": (major, minor, patch + 1), - }[part] - if not release_candidate: - return "{}.{}.{}".format(*target) - if latest.is_prerelease and latest.release == target: - return "{}.{}.{}rc{}".format(*target, latest.pre[1] + 1) - return "{}.{}.{}rc1".format(*target) - - -def main(): - # tomllib requires Python >= 3.11, which the workflows running this - # script provide, but the tests importing next_version() might not - import tomllib - - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--part", choices=["major", "minor", "patch"], required=True) - parser.add_argument( - "--rc", - choices=["true", "false"], - default="false", - help="if the next version is a release candidate", - ) - args = parser.parse_args() - - # Only tags reachable from HEAD, so releases from a release/vX.Y.x branch - # are computed relative to that release series and not the default branch. - tags = subprocess.run( - ["git", "tag", "--list", "v*", "--merged", "HEAD"], - check=True, - capture_output=True, - text=True, - ).stdout.split() - versions = [Version(tag.removeprefix("v")) for tag in tags] - latest = max(versions) - latest_stable = max(version for version in versions if not version.is_prerelease) - version = next_version(args.part, args.rc == "true", latest, latest_stable) - - # The patch release tags of a release series are only reachable from its - # release/vX.Y.x branch, so guard against computing a version bump from a - # branch that is not part of the intended release series (e.g. a patch - # release of an old series attempted from the default branch). - with Path("tbump.toml").open("rb") as manifest: - current = Version(tomllib.load(manifest)["version"]["current"]) - if Version(version) <= current: - error_message = ( - f"ERROR: computed next version {version} is not newer than the" - f" current version {current} in tbump.toml." - " Is this the correct branch for this release?" - ) - raise SystemExit(error_message) - - print(version) - - -if __name__ == "__main__": - main() diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 9fdeb0d375..929cd17d24 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -3,18 +3,10 @@ name: Prepare release on: workflow_dispatch: inputs: - part: - description: 'Semver part of the release (major | minor | patch)' + new_version: + description: 'Version of the release (e.g. 0.8.0 or 0.8.0rc1, no leading v)' required: true - type: choice - options: - - patch - - minor - - major - release_candidate: - type: boolean - description: 'Release candidate' - default: false + type: string permissions: contents: read @@ -26,12 +18,13 @@ jobs: if: github.repository == 'scikit-hep/pyhf' steps: - # The workflow dispatch ref selects the branch to release from + # The workflow dispatch ref selects the branch to release from. + # Use GitHub PAT to authenticate so CI triggers on the pull request. - name: Checkout code uses: actions/checkout@v7 with: fetch-depth: 0 - persist-credentials: false + token: ${{ secrets.ACCESS_TOKEN }} - name: Set up Python uses: actions/setup-python@v7 @@ -44,35 +37,64 @@ jobs: uv pip install --system packaging tbump python -m pip list - - name: Compute next version - id: version + # tbump validates the version format against its regex, and the review of + # the release preparation pull request validates the release version + - name: Validate version is newer than the current version env: - PART: ${{ inputs.part }} - RELEASE_CANDIDATE: ${{ inputs.release_candidate }} + VERSION: ${{ inputs.new_version }} run: | - version="$(python .github/scripts/next_version.py --part "${PART}" --rc "${RELEASE_CANDIDATE}")" - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "## Computed next version: ${version}" >> "$GITHUB_STEP_SUMMARY" + python - <<'EOF' + import os + import tomllib + from pathlib import Path + + from packaging.version import Version + + new_version = os.environ["VERSION"] + with Path("tbump.toml").open("rb") as manifest: + current_version = tomllib.load(manifest)["version"]["current"] + if Version(new_version) <= Version(current_version): + error_message = ( + f"ERROR: {new_version} is not newer than the current version" + f" {current_version}." + " Is this the correct branch for this release?" + ) + raise SystemExit(error_message) + print(f"Bumping version: {current_version} -> {new_version}") + EOF + echo "## Bumping version to ${VERSION}" >> "$GITHUB_STEP_SUMMARY" - name: Bump version in files env: - VERSION: ${{ steps.version.outputs.version }} + VERSION: ${{ inputs.new_version }} run: tbump --non-interactive --only-patch "${VERSION}" - # Use GitHub PAT to authenticate so CI triggers on the pull request - name: Open release preparation pull request - uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 - with: - token: ${{ secrets.ACCESS_TOKEN }} - commit-message: 'chore: Bump version to v${{ steps.version.outputs.version }}' - title: 'chore: Bump version to v${{ steps.version.outputs.version }}' - branch: bump-version/v${{ steps.version.outputs.version }} - base: ${{ github.ref_name }} - delete-branch: true - body: | - Bump version from the latest release to `v${{ steps.version.outputs.version }}` in all files managed by `tbump.toml`. - - Reviewing this pull request is the release "dry run": - * [ ] Verify the computed version is the intended release version. - * [ ] Verify there is a release notes file for the release under `docs/release-notes`. - * [ ] After merging, run the [Tag release workflow](https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml) on this branch to create the release tag. + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + VERSION: ${{ inputs.new_version }} + BASE_BRANCH: ${{ github.ref_name }} + run: | + branch="bump-version/v${VERSION}" + + git config --local user.name "github-actions[bot]" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git switch --create "${branch}" + git commit --all --message "chore: Bump version to v${VERSION}" + git push --set-upstream origin "${branch}" + + cat > "${RUNNER_TEMP}/pull_request_body.md" < Date: Tue, 11 Aug 2026 08:46:05 -0600 Subject: [PATCH 09/48] docs: Describe entering the release version to prepare a release * Update the development docs and the release checklist issue template to describe entering the version of the release in the Prepare release workflow instead of selecting the semver part of the release. Assisted-by: ClaudeCode:claude-fable-5 --- .github/ISSUE_TEMPLATE/~release-checklist.md | 4 ++-- docs/development.rst | 7 +++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/~release-checklist.md b/.github/ISSUE_TEMPLATE/~release-checklist.md index 79755d269c..acf6f179c9 100644 --- a/.github/ISSUE_TEMPLATE/~release-checklist.md +++ b/.github/ISSUE_TEMPLATE/~release-checklist.md @@ -23,8 +23,8 @@ about: Checklist for core developers to complete as part of making a release ## Create Release Tag -* [ ] Run the [Prepare release](https://github.com/scikit-hep/pyhf/actions/workflows/release-prepare.yml) GitHub Actions workflow on the release branch, selecting the SemVer part of the release and if the release is a release candidate. -* [ ] Review the release preparation pull request the workflow opens: verify the computed version and the diff of the bumped files, and wait for CI to pass. +* [ ] Run the [Prepare release](https://github.com/scikit-hep/pyhf/actions/workflows/release-prepare.yml) GitHub Actions workflow on the release branch, entering the version of the new release. +* [ ] Review the release preparation pull request the workflow opens: verify the new version and the diff of the bumped files, and wait for CI to pass. * [ ] Merge the release preparation pull request. * [ ] Run the [Tag release](https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml) GitHub Actions workflow on the release branch and approve the `release-tag` environment deployment. * [ ] Verify the release tag was pushed to the correct branch and that its annotation summarizes the changes since the previous release. diff --git a/docs/development.rst b/docs/development.rst index af9163d9a7..940d733279 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -198,15 +198,14 @@ workflow through workflow dispatch. The maintainer needs to: * Select the branch to release from (``main`` or a ``release/vX.Y.x`` release branch). -* Select the semantic versioning (SemVer) part of the release (major, minor, patch). -* Select if the release is a release candidate or not. +* Input the version of the release (e.g. ``0.8.0`` or ``0.8.0rc1``). -The workflow computes the next release version from the Git tags reachable from the +The workflow validates that the version is newer than the current version on the selected branch and opens a release preparation pull request that bumps the version of all files defined in `tbump.toml `__ to it. The pull request serves as the release dry run: the maintainer should verify the -computed version and the diff of the bumped files, and let CI validate the changes, +new version and the diff of the bumped files, and let CI validate the changes, before merging. Tagging a Release From f315bbea4ae70f823be2a915b79123506d569f1d Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 16:44:52 -0600 Subject: [PATCH 10/48] ci: Validate release version with standalone script * Move the release version validation Python out of a YAML heredoc in the Prepare release workflow and into .github/workflows/validate-version.py so that the code can be validated with tooling outside of the YAML. * Name the base branch of the release preparation pull request explicitly in the pull request body instructions on where to run the Tag release workflow, as after the merge the head branch is deleted and the release tag is created on the base branch. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 22 ++------------------ .github/workflows/validate-version.py | 30 +++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 33 insertions(+), 20 deletions(-) create mode 100644 .github/workflows/validate-version.py diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 929cd17d24..87cccce465 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -43,25 +43,7 @@ jobs: env: VERSION: ${{ inputs.new_version }} run: | - python - <<'EOF' - import os - import tomllib - from pathlib import Path - - from packaging.version import Version - - new_version = os.environ["VERSION"] - with Path("tbump.toml").open("rb") as manifest: - current_version = tomllib.load(manifest)["version"]["current"] - if Version(new_version) <= Version(current_version): - error_message = ( - f"ERROR: {new_version} is not newer than the current version" - f" {current_version}." - " Is this the correct branch for this release?" - ) - raise SystemExit(error_message) - print(f"Bumping version: {current_version} -> {new_version}") - EOF + python .github/workflows/validate-version.py "${VERSION}" echo "## Bumping version to ${VERSION}" >> "$GITHUB_STEP_SUMMARY" - name: Bump version in files @@ -90,7 +72,7 @@ jobs: Reviewing this pull request is the release "dry run": * [ ] Verify the new version is the intended release version. * [ ] Verify there is a release notes file for the release under docs/release-notes. - * [ ] After merging, run the [Tag release workflow](https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml) on this branch to create the release tag. + * [ ] After merging, run the [Tag release workflow](https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml) on the ${BASE_BRANCH} branch to create the release tag. EOF gh pr create \ diff --git a/.github/workflows/validate-version.py b/.github/workflows/validate-version.py new file mode 100644 index 0000000000..7ff6a659af --- /dev/null +++ b/.github/workflows/validate-version.py @@ -0,0 +1,30 @@ +"""Validate that a release version is newer than the current release version.""" + +import argparse +from pathlib import Path + +import tomllib +from packaging.version import Version + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "version", help="version of the release (e.g. 0.8.0 or 0.8.0rc1)" + ) + args = parser.parse_args() + + with Path("tbump.toml").open("rb") as manifest: + current_version = tomllib.load(manifest)["version"]["current"] + if Version(args.version) <= Version(current_version): + error_message = ( + f"ERROR: {args.version} is not newer than the current version" + f" {current_version}." + " Is this the correct branch for this release?" + ) + raise SystemExit(error_message) + print(f"Bumping version: {current_version} -> {args.version}") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index ebd5620b34..c8b308c9f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -314,6 +314,7 @@ typing-modules = ["pyhf.typing"] flake8-tidy-imports.ban-relative-imports = "all" [tool.ruff.lint.per-file-ignores] +".github/workflows/validate-version.py" = ["T20"] "docs/jupyterlite/jupyterlite.py" = ["F401", "F704"] "**.ipynb" = ["T20"] "noxfile.py" = ["T20"] From 585bfeeb9ad3c6ec6321c10aac01ba1f9afe7d11 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 17:03:42 -0600 Subject: [PATCH 11/48] docs: Document release branch creation * Document in the development docs that each minor release series has a release/vX.Y.x release branch, created from the release tag, to support future patch releases for the series. * Note that creating the release branch from the release tag makes the tag reachable from the branch, which the release workflows require to validate patch release versions against the release series and to summarize the changes since the previous release in the release tag annotation. Assisted-by: ClaudeCode:claude-fable-5 --- docs/development.rst | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/docs/development.rst b/docs/development.rst index 940d733279..98097fb898 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -235,6 +235,32 @@ branch, creating and pushing the release tag git tag --annotate vX.Y.Z --message "pyhf vX.Y.Z" git push origin vX.Y.Z +Release Branches +~~~~~~~~~~~~~~~~ + +Each minor release series has a corresponding release branch, named +``release/vX.Y.x`` (e.g. ``release/v0.8.x``), so that patch releases for the +series can be made after development on ``main`` has moved on to the next +release series. +After a minor or major release has been tagged, a maintainer can create the +release branch from the release tag and push it to the repository + +.. code-block:: console + + git fetch origin + git branch release/vX.Y.x vX.Y.0 + git push origin release/vX.Y.x + +Creating the release branch from the release tag makes the tag reachable from +the branch, which the release workflows require to validate the version of a +patch release against the release series and to summarize the changes since the +previous release in the tag annotation. + +Patch releases follow the same release procedure as all other releases, with +the ``release/vX.Y.x`` branch selected when running the `Prepare release`_ and +`Tag release`_ workflows, and with the changes for the patch release landing on +the release branch as backports of pull requests merged into ``main``. + Deployment ~~~~~~~~~~ From 3c08a9d40f1ebcbb331092a43d9360bd77fbb142 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 17:51:16 -0600 Subject: [PATCH 12/48] ci: Use simple release tag annotation * Annotate the release tag with only the release name, removing the generation of a changelog in the tag annotation. The changes in a release are summarized by the auto-generated GitHub release notes, which .github/release.yml configures to categorize changes and exclude bot commits, and by the curated release notes under docs/release-notes. * The changelog generation selected the previous release tag from the tags reachable from HEAD, which on the default branch misidentified the previous release when patch releases were tagged only on release branches, and silently produced an empty changelog when no stable tag was reachable. Removing the generation removes these failure modes. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-tag.yml | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index eea543351a..00ecf595b8 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -34,7 +34,9 @@ jobs: version="$(python3 -c 'import tomllib; print(tomllib.load(open("tbump.toml", "rb"))["version"]["current"])')" echo "version=${version}" >> "$GITHUB_OUTPUT" - - name: Create annotated tag with changelog + # The changes in the release are summarized by the auto-generated GitHub + # release notes, configured through .github/release.yml + - name: Create annotated tag env: VERSION: ${{ steps.version.outputs.version }} run: | @@ -43,23 +45,9 @@ jobs: exit 1 fi - if [[ "${VERSION}" == *rc* ]]; then - previous_tag="$(git describe --tags --abbrev=0)" - else - # Stable releases list the changes since the previous stable - # release, spanning any release candidate series - previous_tag="$(git tag --list 'v*' --merged HEAD --sort=v:refname | grep --invert-match rc | tail -n 1)" - fi - - changes="$(git log --pretty=format:' - %s' "${previous_tag}"..HEAD \ - --regexp-ignore-case --extended-regexp \ - --grep='^[a-z]+(\([a-z0-9._-]+\))?!?:' \ - --grep='\(backport\):')" - git config --local user.name "github-actions[bot]" git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" - git tag --annotate "v${VERSION}" \ - --message "$(printf 'pyhf v%s\n\nChanges from %s:\n%s' "${VERSION}" "${previous_tag}" "${changes}")" + git tag --annotate "v${VERSION}" --message "pyhf v${VERSION}" git tag -n99 --list "v${VERSION}" From 90a731e965ffa6087bce7ef888274544ab018940 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 17:51:47 -0600 Subject: [PATCH 13/48] docs: Remove release tag changelog references * Update the development docs and the release checklist issue template for the release tag annotation no longer containing a changelog. The auto-generated GitHub release notes and the curated release notes under docs/release-notes are the changelog sources. * Correct the release branch documentation to accurately describe why the release branch is created from the release tag: hatch-vcs requires the tag to be reachable to derive the release series versions for distributions built from the branch, and the tbump.toml on the branch scopes patch release version validation to the release series. Assisted-by: ClaudeCode:claude-fable-5 --- .github/ISSUE_TEMPLATE/~release-checklist.md | 3 +-- docs/development.rst | 12 +++++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/~release-checklist.md b/.github/ISSUE_TEMPLATE/~release-checklist.md index acf6f179c9..413febebaa 100644 --- a/.github/ISSUE_TEMPLATE/~release-checklist.md +++ b/.github/ISSUE_TEMPLATE/~release-checklist.md @@ -27,14 +27,13 @@ about: Checklist for core developers to complete as part of making a release * [ ] Review the release preparation pull request the workflow opens: verify the new version and the diff of the bumped files, and wait for CI to pass. * [ ] Merge the release preparation pull request. * [ ] Run the [Tag release](https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml) GitHub Actions workflow on the release branch and approve the `release-tag` environment deployment. -* [ ] Verify the release tag was pushed to the correct branch and that its annotation summarizes the changes since the previous release. +* [ ] Verify the release tag was pushed to the correct branch. * [ ] Watch the CI to verify all tag based jobs finish correctly. * [ ] Verify the release for the tag on [TestPyPI][TestPyPI_pyhf] looks correct. ## After Release Tag Pushed To GitHub * [ ] Create a [GitHub release](https://github.com/scikit-hep/pyhf/releases) from the new release tag and copy the release notes published to the GitHub release page. The creation of the GitHub release triggers all other release related activities. - - [ ] Copy the changes from the release tag's annotation into the "Changes" section of the release notes. - [ ] Create a corresponding [announcement GitHub Discussion](https://github.com/scikit-hep/pyhf/discussions/categories/announcements) for the release. * [ ] Watch the CI to ensure that the deployment to [PyPI](https://pypi.org/project/pyhf/) is successful. * [ ] Verify Docker images with the correct tags have been deployed to all container image registries. diff --git a/docs/development.rst b/docs/development.rst index 98097fb898..946ff94e99 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -216,8 +216,7 @@ After the release preparation pull request has been merged, a maintainer runs th branch. The workflow requires approval through the ``release-tag`` GitHub Actions environment, and then creates an annotated tag for the version defined in -``tbump.toml`` — with an annotation summarizing the changes since the previous -release — and pushes the tag to the release branch. +``tbump.toml`` and pushes the tag to the release branch. If the release workflows are not available on the release branch (e.g. historic release branches) a maintainer can perform the same steps locally by bumping the @@ -252,9 +251,12 @@ release branch from the release tag and push it to the repository git push origin release/vX.Y.x Creating the release branch from the release tag makes the tag reachable from -the branch, which the release workflows require to validate the version of a -patch release against the release series and to summarize the changes since the -previous release in the tag annotation. +the branch, which ``hatch-vcs`` requires to correctly derive the release series +versions for the distributions built from the branch (e.g. dev versions for +untagged commits). +As the ``tbump.toml`` on the release branch records the latest release of its +release series, the version validation of a patch release prepared from the +branch is automatically scoped to the release series. Patch releases follow the same release procedure as all other releases, with the ``release/vX.Y.x`` branch selected when running the `Prepare release`_ and From d4536e990eb56533ae1c6999c93745e5e8d649ae Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 17:52:19 -0600 Subject: [PATCH 14/48] ci: Validate release version format and tag uniqueness * Validate the release version format against the same regex as tbump.toml in validate-version.py, to fail with a clear error at the validation step instead of an opaque tbump error one step later. packaging.version.Version accepts version formats that tbump rejects (e.g. a leading "v", 0.8.0-rc1, 0.8.0.post1). * Error in the Prepare release workflow if a Git tag already exists for the release version, which catches versions already released from another release series branch at dispatch time instead of after the release preparation pull request has been reviewed and merged. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 14 +++++++++++--- .github/workflows/validate-version.py | 11 +++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 87cccce465..4c2e5de2c6 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -37,13 +37,21 @@ jobs: uv pip install --system packaging tbump python -m pip list - # tbump validates the version format against its regex, and the review of - # the release preparation pull request validates the release version - - name: Validate version is newer than the current version + # The review of the release preparation pull request validates the + # release version + - name: Validate version env: VERSION: ${{ inputs.new_version }} run: | python .github/workflows/validate-version.py "${VERSION}" + + # Catch versions already released from another release series branch + # (fetch-depth: 0 fetches all tags, including release branch tags) + if git rev-parse --quiet --verify "refs/tags/v${VERSION}" > /dev/null; then + echo "ERROR: tag v${VERSION} already exists." >&2 + exit 1 + fi + echo "## Bumping version to ${VERSION}" >> "$GITHUB_STEP_SUMMARY" - name: Bump version in files diff --git a/.github/workflows/validate-version.py b/.github/workflows/validate-version.py index 7ff6a659af..b18dfce817 100644 --- a/.github/workflows/validate-version.py +++ b/.github/workflows/validate-version.py @@ -1,6 +1,7 @@ """Validate that a release version is newer than the current release version.""" import argparse +import re from pathlib import Path import tomllib @@ -14,6 +15,16 @@ def main(): ) args = parser.parse_args() + # Same version format that the tbump.toml regex enforces, checked here to + # fail with a clear error before tbump runs (packaging.version.Version + # would otherwise accept versions tbump rejects, e.g. a leading "v") + if not re.fullmatch(r"\d+\.\d+\.\d+(rc\d+)?", args.version): + error_message = ( + f"ERROR: {args.version} does not match the release version format" + " X.Y.Z or X.Y.ZrcN (with no leading v)." + ) + raise SystemExit(error_message) + with Path("tbump.toml").open("rb") as manifest: current_version = tomllib.load(manifest)["version"]["current"] if Version(args.version) <= Version(current_version): From 29c8c05a7859011d99a471651c2e0501343f45da Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 17:52:39 -0600 Subject: [PATCH 15/48] ci: Force push release preparation branch * Force push the bump-version/vX.Y.Z branch when opening a release preparation pull request. The branch is owned by the workflow, and a release preparation pull request that was closed without merging leaves the branch behind, which would reject a normal push when the workflow is dispatched again for the same version. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 4c2e5de2c6..63559949be 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -72,7 +72,10 @@ jobs: git switch --create "${branch}" git commit --all --message "chore: Bump version to v${VERSION}" - git push --set-upstream origin "${branch}" + # The release preparation branch is owned by this workflow, so + # replace any leftover branch from a closed release preparation + # pull request for the same version + git push --force --set-upstream origin "${branch}" cat > "${RUNNER_TEMP}/pull_request_body.md" < Date: Tue, 11 Aug 2026 17:53:03 -0600 Subject: [PATCH 16/48] ci: Scope build warnings check to scheduled builds * Run the weekly warnings-as-errors build check as a dedicated schedule-only build step instead of setting PYTHONWARNINGS on the build-and-inspect-python-package composite action, where the env is inherited by every Python process the action runs (twine, check-wheel-contents, wheel) and unrelated warnings from those tools would error the check. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index b744c36913..eb58177ae2 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -67,9 +67,14 @@ jobs: - name: Build a sdist and wheel id: baipp uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 + + # Scoped to a dedicated build so that warnings from other tools that + # build-and-inspect-python-package runs don't error the check + - name: Build a sdist and wheel and check for warnings + if: github.event_name == 'schedule' env: - # Weekly check that builds are warning free - PYTHONWARNINGS: ${{ github.event_name == 'schedule' && 'error,default::DeprecationWarning' || '' }} + PYTHONWARNINGS: 'error,default::DeprecationWarning' + run: uvx --from 'build[uv]' pyproject-build --installer uv --outdir "${RUNNER_TEMP}/warnings-check-dist" . - name: Verify untagged commits have dev versions if: "!startsWith(github.ref, 'refs/tags/')" From a0191601af34ce22444773683c7aa3d93e0991f5 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 17:53:36 -0600 Subject: [PATCH 17/48] ci: Publish to TestPyPI and PyPI with a single job * Merge the publish-testpypi and publish-pypi jobs, which differed only in the repository-url input, into a single publish job that selects the package index from the build job's publish target output. This keeps the TestPyPI deployments exercising the exact code path used for PyPI deployments. * The trusted publisher configuration is unchanged: the OIDC claims bind the workflow file and the GitHub Actions environment, both of which are preserved. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 52 ++++----------------------- 1 file changed, 7 insertions(+), 45 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index eb58177ae2..0279e6dabf 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -117,10 +117,10 @@ jobs: with: subject-path: "${{ steps.baipp.outputs.dist }}/pyhf-*" - publish-testpypi: - name: Publish Python distribution to TestPyPI + publish: + name: Publish Python distribution to (Test)PyPI needs: build - if: needs.build.outputs.publish-target == 'testpypi' + if: needs.build.outputs.publish-target != '' runs-on: ubuntu-latest # Mandatory for publishing with a trusted publisher # c.f. https://docs.pypi.org/trusted-publishers/using-a-publisher/ @@ -152,48 +152,10 @@ jobs: REPOSITORY: ${{ github.repository }} run: gh attestation verify dist/pyhf-*.whl --repo "${REPOSITORY}" - - name: Publish distribution 📦 to Test PyPI - uses: pypa/gh-action-pypi-publish@v1.14.2 - with: - repository-url: https://test.pypi.org/legacy/ - print-hash: true - - publish-pypi: - name: Publish Python distribution to PyPI - needs: build - if: needs.build.outputs.publish-target == 'pypi' - runs-on: ubuntu-latest - # Mandatory for publishing with a trusted publisher - # c.f. https://docs.pypi.org/trusted-publishers/using-a-publisher/ - permissions: - id-token: write - # Restrict to the environment set for the trusted publisher - environment: - name: publish-package - - steps: - - name: Download distribution artifact - uses: actions/download-artifact@v8 - with: - name: Packages - path: dist - - - name: List all files - run: ls -lh dist - - - name: Verify sdist artifact attestation - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPOSITORY: ${{ github.repository }} - run: gh attestation verify dist/pyhf-*.tar.gz --repo "${REPOSITORY}" - - - name: Verify wheel artifact attestation - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPOSITORY: ${{ github.repository }} - run: gh attestation verify dist/pyhf-*.whl --repo "${REPOSITORY}" - - - name: Publish distribution 📦 to PyPI + - name: Publish distribution 📦 to (Test)PyPI uses: pypa/gh-action-pypi-publish@v1.14.2 with: + # Both URLs explicit, as an empty string would override the action's + # default URL instead of selecting it + repository-url: ${{ needs.build.outputs.publish-target == 'testpypi' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} print-hash: true From 10ad2b2eb72d5fa86c4a7f6246c8aa9477554952 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 17:53:57 -0600 Subject: [PATCH 18/48] ci: Restrict release preparation to release branches * Error the Prepare release workflow unless it is dispatched on main or a release/vX.Y.x branch, restoring the release branch check from the removed bump version workflow. This also rejects dispatches on tag refs, which would push an orphaned release preparation branch and then fail to open a pull request. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 63559949be..58cad06d1b 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -18,6 +18,14 @@ jobs: if: github.repository == 'scikit-hep/pyhf' steps: + - name: Check branch is intended for release + run: | + if [[ "${GITHUB_REF}" != "refs/heads/main" && "${GITHUB_REF}" != refs/heads/release/v* ]]; then + echo "ERROR: ${GITHUB_REF} is not intended for release." >&2 + echo " Releases are made only from main or release/vX.Y.x branches." >&2 + exit 1 + fi + # The workflow dispatch ref selects the branch to release from. # Use GitHub PAT to authenticate so CI triggers on the pull request. - name: Checkout code From 59f53f23345c0fad62f29ffb3971ea873228ece2 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 17:57:41 -0600 Subject: [PATCH 19/48] docs: Use a version more obviously SemVer --- docs/development.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 946ff94e99..c11268148d 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -198,7 +198,7 @@ workflow through workflow dispatch. The maintainer needs to: * Select the branch to release from (``main`` or a ``release/vX.Y.x`` release branch). -* Input the version of the release (e.g. ``0.8.0`` or ``0.8.0rc1``). +* Input the version of the release (e.g. ``1.2.3`` or ``1.2.3rc1``). The workflow validates that the version is newer than the current version on the selected branch and opens a release preparation pull request that bumps the version @@ -238,7 +238,7 @@ Release Branches ~~~~~~~~~~~~~~~~ Each minor release series has a corresponding release branch, named -``release/vX.Y.x`` (e.g. ``release/v0.8.x``), so that patch releases for the +``release/vX.Y.x`` (e.g. ``release/v1.2.x``), so that patch releases for the series can be made after development on ``main`` has moved on to the next release series. After a minor or major release has been tagged, a maintainer can create the From 8a980fc90d5c7b3e5574519b78829e6313efbec1 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:00:51 -0600 Subject: [PATCH 20/48] remove emjoi from name --- .github/workflows/publish-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 0279e6dabf..784dac794f 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -152,7 +152,7 @@ jobs: REPOSITORY: ${{ github.repository }} run: gh attestation verify dist/pyhf-*.whl --repo "${REPOSITORY}" - - name: Publish distribution 📦 to (Test)PyPI + - name: Publish distribution to (Test)PyPI uses: pypa/gh-action-pypi-publish@v1.14.2 with: # Both URLs explicit, as an empty string would override the action's From b4b6a6d7f28876c66509a4aec96eca653e017adb Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:04:24 -0600 Subject: [PATCH 21/48] ci: Separate TestPyPI and PyPI publish steps * Use separate pypa/gh-action-pypi-publish steps for the TestPyPI and PyPI uploads, selected by the build job's publish target output, instead of a single step with a conditional repository-url. This avoids hardcoding the PyPI upload URL and keeps the PyPI upload matching the pypa/gh-action-pypi-publish README usage, while the publish job still shares the artifact download and attestation verification steps across both targets. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 784dac794f..87e5ca01ee 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -152,10 +152,15 @@ jobs: REPOSITORY: ${{ github.repository }} run: gh attestation verify dist/pyhf-*.whl --repo "${REPOSITORY}" - - name: Publish distribution to (Test)PyPI + - name: Publish distribution to Test PyPI + if: needs.build.outputs.publish-target == 'testpypi' + uses: pypa/gh-action-pypi-publish@v1.14.2 + with: + repository-url: https://test.pypi.org/legacy/ + print-hash: true + + - name: Publish distribution to PyPI + if: needs.build.outputs.publish-target == 'pypi' uses: pypa/gh-action-pypi-publish@v1.14.2 with: - # Both URLs explicit, as an empty string would override the action's - # default URL instead of selecting it - repository-url: ${{ needs.build.outputs.publish-target == 'testpypi' && 'https://test.pypi.org/legacy/' || 'https://upload.pypi.org/legacy/' }} print-hash: true From 4f81f7dc8f1558f3c2cb1f7e2b2270ec3d775189 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:36:34 -0600 Subject: [PATCH 22/48] ci: Require canonical release versions * Require the release version to be the canonical form of the version under PEP 440 normalization, so that non-canonical versions like 0.8.00 are rejected instead of being released everywhere as-is while normalizing equal to 0.8.0, which would block a later 0.8.0 release from validating as newer. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/validate-version.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/validate-version.py b/.github/workflows/validate-version.py index b18dfce817..f24f950216 100644 --- a/.github/workflows/validate-version.py +++ b/.github/workflows/validate-version.py @@ -25,6 +25,15 @@ def main(): ) raise SystemExit(error_message) + # packaging normalizes versions (e.g. 0.8.00 to 0.8.0), so require the + # canonical form to keep the released version identical everywhere + if args.version != str(Version(args.version)): + error_message = ( + f"ERROR: {args.version} is not the canonical form" + f" {Version(args.version)} of the version." + ) + raise SystemExit(error_message) + with Path("tbump.toml").open("rb") as manifest: current_version = tomllib.load(manifest)["version"]["current"] if Version(args.version) <= Version(current_version): From 218b3a566840061dbf51d79cf97c1b76fe83f308 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:37:08 -0600 Subject: [PATCH 23/48] ci: Validate release versions with the tbump.toml version regex * Validate the release version format with the version regex read from tbump.toml instead of a hand-maintained copy of it, making tbump.toml the single source of truth for the version format. This keeps the fail-early validation and tbump from drifting apart if the version format is ever revised. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/validate-version.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/validate-version.py b/.github/workflows/validate-version.py index f24f950216..72d8d338bb 100644 --- a/.github/workflows/validate-version.py +++ b/.github/workflows/validate-version.py @@ -15,13 +15,17 @@ def main(): ) args = parser.parse_args() - # Same version format that the tbump.toml regex enforces, checked here to - # fail with a clear error before tbump runs (packaging.version.Version - # would otherwise accept versions tbump rejects, e.g. a leading "v") - if not re.fullmatch(r"\d+\.\d+\.\d+(rc\d+)?", args.version): + with Path("tbump.toml").open("rb") as manifest: + version_config = tomllib.load(manifest)["version"] + + # Validate with the tbump.toml version regex, which tbump compiles in + # verbose mode, to fail with a clear error here before tbump runs + # (packaging.version.Version would otherwise accept versions tbump + # rejects, e.g. a leading "v") + if not re.fullmatch(version_config["regex"], args.version, flags=re.VERBOSE): error_message = ( - f"ERROR: {args.version} does not match the release version format" - " X.Y.Z or X.Y.ZrcN (with no leading v)." + f"ERROR: {args.version} does not match the tbump.toml release" + " version format X.Y.Z or X.Y.ZrcN (with no leading v)." ) raise SystemExit(error_message) @@ -34,8 +38,7 @@ def main(): ) raise SystemExit(error_message) - with Path("tbump.toml").open("rb") as manifest: - current_version = tomllib.load(manifest)["version"]["current"] + current_version = version_config["current"] if Version(args.version) <= Version(current_version): error_message = ( f"ERROR: {args.version} is not newer than the current version" From d1fb515b71a930a522f08f8a43c8bea2858b06a4 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:37:32 -0600 Subject: [PATCH 24/48] ci: Update open release preparation pull request on rerun * Only create a release preparation pull request if no open pull request for the release preparation branch exists. On a rerun of the Prepare release workflow for the same version the force push already updates the open pull request, and attempting to create a second one would error the workflow after the push succeeded. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 58cad06d1b..eaf4f7fecb 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -94,8 +94,15 @@ jobs: * [ ] After merging, run the [Tag release workflow](https://github.com/scikit-hep/pyhf/actions/workflows/release-tag.yml) on the ${BASE_BRANCH} branch to create the release tag. EOF - gh pr create \ - --base "${BASE_BRANCH}" \ - --head "${branch}" \ - --title "chore: Bump version to v${VERSION}" \ - --body-file "${RUNNER_TEMP}/pull_request_body.md" + # A rerun's force push has already updated any open release + # preparation pull request for this version, so only create one + # if none exists + if [ "$(gh pr list --head "${branch}" --state open --json number --jq 'length')" -eq 0 ]; then + gh pr create \ + --base "${BASE_BRANCH}" \ + --head "${branch}" \ + --title "chore: Bump version to v${VERSION}" \ + --body-file "${RUNNER_TEMP}/pull_request_body.md" + else + echo "The open release preparation pull request for ${branch} was updated by the force push." + fi From f0a870242bd0de15e937bb4e6c71eee78d750c95 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:37:57 -0600 Subject: [PATCH 25/48] ci: Restrict release tagging to release branches * Error the Tag release workflow unless it is dispatched on main or a release/vX.Y.x branch, matching the guard in the Prepare release workflow. The release-tag environment's required reviewers and deployment branch policy provide this restriction when configured, but the protection rules live in the repository settings and GitHub auto-creates a referenced environment without protection rules, so guard in code as well to fail closed. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-tag.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 00ecf595b8..9f2f38c32f 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -20,6 +20,17 @@ jobs: name: release-tag steps: + # Fail closed in code as well, as the release-tag environment's + # protection rules live in the repository settings and GitHub + # auto-creates a referenced environment without protection rules + - name: Check branch is intended for release + run: | + if [[ "${GITHUB_REF}" != "refs/heads/main" && "${GITHUB_REF}" != refs/heads/release/v* ]]; then + echo "ERROR: ${GITHUB_REF} is not intended for release." >&2 + echo " Releases are made only from main or release/vX.Y.x branches." >&2 + exit 1 + fi + # Use GitHub PAT to authenticate so the tag push triggers the publishing # and Docker workflows - name: Checkout code From c2b6ba029de383dd232a82950c51a9c2aaf4c56c Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:38:17 -0600 Subject: [PATCH 26/48] ci: Separate publish workflow concurrency by event * Include the event name in the publish workflow's concurrency group. The tag push run deploying to TestPyPI and the GitHub release run deploying to PyPI share the same refs/tags/vX.Y.Z ref, so with cancel-in-progress publishing the GitHub release could cancel an in-progress TestPyPI upload of the same tag, potentially leaving a partial TestPyPI release that can not be re-uploaded. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 87e5ca01ee..9d21ba9054 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -22,7 +22,10 @@ on: default: false concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + # Include the event name so that the release event run of a tag can not + # cancel the tag push event run mid-upload to TestPyPI (both runs share + # the same refs/tags/vX.Y.Z ref) + group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: true permissions: From 89cc6b7a6d086b4a83a7a48eeca20068cbddf6ea Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:38:53 -0600 Subject: [PATCH 27/48] docs: Document release environment setup and abandoned releases * Document that the release-tag GitHub Actions environment must be configured in the repository settings with required reviewers and deployment branches restricted to main and release/v*, as GitHub creates a referenced environment without any protection rules. * Document that recovering an abandoned release whose release preparation pull request was already merged requires reverting the pull request before a lower version (e.g. a release candidate) can be prepared. * Update the release checklist forward porting item to reference the tbump.toml version information instead of the removed bumpversion tooling. Assisted-by: ClaudeCode:claude-fable-5 --- .github/ISSUE_TEMPLATE/~release-checklist.md | 2 +- docs/development.rst | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/~release-checklist.md b/.github/ISSUE_TEMPLATE/~release-checklist.md index 413febebaa..1078f5083e 100644 --- a/.github/ISSUE_TEMPLATE/~release-checklist.md +++ b/.github/ISSUE_TEMPLATE/~release-checklist.md @@ -51,7 +51,7 @@ about: Checklist for core developers to complete as part of making a release * [ ] Verify that the release is installable from both [PyPI](https://pypi.org/project/pyhf/) and [Conda-forge](https://github.com/conda-forge/pyhf-feedstock). * [ ] Send the drafted [``pyhf-announcements``](https://groups.google.com/group/pyhf-announcements/) email out from the ``pyhf-announcements`` account email. * [ ] Tweet the release out on both personal and team Twitter accounts. -* [ ] Forward port the release notes and bumpversion information from the release branch to the default branch. +* [ ] Forward port the release notes and the `tbump.toml` version information from the release branch to the default branch. - c.f. PR https://github.com/scikit-hep/pyhf/pull/2217 and PR https://github.com/scikit-hep/pyhf/pull/2218 as examples from `pyhf` `v0.7.2`. * [ ] Announce the release on the [Scikit-HEP community Gitter](https://gitter.im/Scikit-HEP/community). * [ ] Make a release for the [`pyhf` tutorial](https://github.com/pyhf/pyhf-tutorial/releases) corresponding to the **previous release** number. This release represents the last version of the tutorial that is guaranteed to work with previous release API. diff --git a/docs/development.rst b/docs/development.rst index c11268148d..5e855931f6 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -208,6 +208,15 @@ The pull request serves as the release dry run: the maintainer should verify the new version and the diff of the bumped files, and let CI validate the changes, before merging. +.. note:: + + The version validation is relative to the version recorded in ``tbump.toml``, + which merging a release preparation pull request updates. + If a release is abandoned after its release preparation pull request has been + merged, but before the release tag has been created, revert the release + preparation pull request to be able to prepare a release with a lower version + (e.g. a release candidate of the abandoned release). + Tagging a Release ~~~~~~~~~~~~~~~~~ @@ -218,6 +227,13 @@ The workflow requires approval through the ``release-tag`` GitHub Actions environment, and then creates an annotated tag for the version defined in ``tbump.toml`` and pushes the tag to the release branch. +The ``release-tag`` `GitHub Actions environment +`__ +must be configured in the repository settings with the maintainers as required +reviewers and with the deployment branches restricted to ``main`` and +``release/v*``, as GitHub creates a referenced environment without any +protection rules. + If the release workflows are not available on the release branch (e.g. historic release branches) a maintainer can perform the same steps locally by bumping the version of the files From 809ebcaa74b9ad5dfb58c6c07fd32a87548958ce Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:43:38 -0600 Subject: [PATCH 28/48] twitter is dead --- .github/ISSUE_TEMPLATE/~release-checklist.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/~release-checklist.md b/.github/ISSUE_TEMPLATE/~release-checklist.md index 1078f5083e..0c6c6850d9 100644 --- a/.github/ISSUE_TEMPLATE/~release-checklist.md +++ b/.github/ISSUE_TEMPLATE/~release-checklist.md @@ -50,7 +50,7 @@ about: Checklist for core developers to complete as part of making a release * [ ] Verify that the release is installable from both [PyPI](https://pypi.org/project/pyhf/) and [Conda-forge](https://github.com/conda-forge/pyhf-feedstock). * [ ] Send the drafted [``pyhf-announcements``](https://groups.google.com/group/pyhf-announcements/) email out from the ``pyhf-announcements`` account email. -* [ ] Tweet the release out on both personal and team Twitter accounts. +* [ ] Share the release on both personal and team social media accounts. * [ ] Forward port the release notes and the `tbump.toml` version information from the release branch to the default branch. - c.f. PR https://github.com/scikit-hep/pyhf/pull/2217 and PR https://github.com/scikit-hep/pyhf/pull/2218 as examples from `pyhf` `v0.7.2`. * [ ] Announce the release on the [Scikit-HEP community Gitter](https://gitter.im/Scikit-HEP/community). From 5df0e3337bbe3de79928de17f576d52b2614d843 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:44:22 -0600 Subject: [PATCH 29/48] docs: Fix capitalization of 'conda-forge' --- .github/ISSUE_TEMPLATE/~release-checklist.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/~release-checklist.md b/.github/ISSUE_TEMPLATE/~release-checklist.md index 0c6c6850d9..c05145fb4e 100644 --- a/.github/ISSUE_TEMPLATE/~release-checklist.md +++ b/.github/ISSUE_TEMPLATE/~release-checklist.md @@ -43,12 +43,12 @@ about: Checklist for core developers to complete as part of making a release * [ ] Verify there is a new [Zenodo DOI](https://doi.org/10.5281/zenodo.1169739) minted for the release. - [ ] Verify that the new release archive metadata on Zenodo matches is being picked up as expected from [`CITATION.cff`](https://github.com/scikit-hep/pyhf/blob/main/CITATION.cff). * [ ] Verify that a Binder has properly built for the new release. -* [ ] Watch for a GitHub notification that there is an automatic PR to the [Conda-forge feedstock](https://github.com/conda-forge/pyhf-feedstock). This may take multiple hours to happen. If there are any changes needed to the Conda-forge release make them **from a personal account** and not from an organization account to have workflows properly trigger. - - [ ] Verify the requirements in the [Conda-forge feedstock](https://github.com/conda-forge/pyhf-feedstock) recipe `meta.yaml` match those in `pyproject.toml`. +* [ ] Watch for a GitHub notification that there is an automatic PR to the [conda-forge feedstock](https://github.com/conda-forge/pyhf-feedstock). This may take multiple hours to happen. If there are any changes needed to the conda-forge release make them **from a personal account** and not from an organization account to have workflows properly trigger. + - [ ] Verify the requirements in the [conda-forge feedstock](https://github.com/conda-forge/pyhf-feedstock) recipe `meta.yaml` match those in `pyproject.toml`. ## After Release -* [ ] Verify that the release is installable from both [PyPI](https://pypi.org/project/pyhf/) and [Conda-forge](https://github.com/conda-forge/pyhf-feedstock). +* [ ] Verify that the release is installable from both [PyPI](https://pypi.org/project/pyhf/) and [conda-forge](https://github.com/conda-forge/pyhf-feedstock). * [ ] Send the drafted [``pyhf-announcements``](https://groups.google.com/group/pyhf-announcements/) email out from the ``pyhf-announcements`` account email. * [ ] Share the release on both personal and team social media accounts. * [ ] Forward port the release notes and the `tbump.toml` version information from the release branch to the default branch. From 8ab13b3432a1137073015419ae17d8d49c9fdce9 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:46:54 -0600 Subject: [PATCH 30/48] ci: Run release preparation tooling with uv * Declare the version validation script's dependencies with PEP 723 inline script metadata and run it with 'uv run', and run tbump with 'uvx', removing the Python setup and dependency installation bootstrap steps. uv is preinstalled on the GitHub Actions runners and this matches the publish workflow's toolchain pattern. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 15 ++------------- .github/workflows/validate-version.py | 4 ++++ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index eaf4f7fecb..a2523a0292 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -34,24 +34,13 @@ jobs: fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: '3.14' - - - name: Install Python dependencies - run: | - python -m pip install uv - uv pip install --system packaging tbump - python -m pip list - # The review of the release preparation pull request validates the # release version - name: Validate version env: VERSION: ${{ inputs.new_version }} run: | - python .github/workflows/validate-version.py "${VERSION}" + uv run --no-project .github/workflows/validate-version.py "${VERSION}" # Catch versions already released from another release series branch # (fetch-depth: 0 fetches all tags, including release branch tags) @@ -65,7 +54,7 @@ jobs: - name: Bump version in files env: VERSION: ${{ inputs.new_version }} - run: tbump --non-interactive --only-patch "${VERSION}" + run: uvx tbump --non-interactive --only-patch "${VERSION}" - name: Open release preparation pull request env: diff --git a/.github/workflows/validate-version.py b/.github/workflows/validate-version.py index 72d8d338bb..39b5daca3a 100644 --- a/.github/workflows/validate-version.py +++ b/.github/workflows/validate-version.py @@ -1,3 +1,7 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["packaging"] +# /// """Validate that a release version is newer than the current release version.""" import argparse From 394fe1717e0a4007683eaa904c47501c9e9db11b Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 18:48:32 -0600 Subject: [PATCH 31/48] ci: Exempt tagged commits from the dev version guard * Exempt a branch HEAD that is exactly at a release tag from the check that untagged commits build dev versions, using 'git describe --exact-match' instead of comparing the latest reachable tag's commit to origin/main. A workflow dispatch on a release branch whose HEAD is the tagged release commit correctly builds a non-dev version and previously errored with a misleading message, as the origin/main exemption did not apply there. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 9d21ba9054..5e8a3c21bd 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -84,19 +84,17 @@ jobs: env: DIST_PATH: ${{ steps.baipp.outputs.dist }} run: | - latest_tag=$(git describe --tags) - latest_tag_revlist_SHA=$(git rev-list -n 1 "${latest_tag}") - main_SHA="$(git rev-parse --verify origin/main)" wheel_name=$(find "${DIST_PATH}" -iname "*.whl" -printf "%f\n") - if [[ "${latest_tag_revlist_SHA}" != "${main_SHA}" ]]; then # don't check main push events coming from tags - if [[ "${wheel_name}" == *"pyhf-0.1.dev"* || "${wheel_name}" != *"dev"* ]]; then + # A branch HEAD exactly at a release tag correctly builds a non-dev + # version (e.g. a push to main of a tagged release commit, or a + # dispatch on a release branch directly after its release) + if release_tag="$(git describe --tags --exact-match 2> /dev/null)"; then + echo "HEAD is at release tag ${release_tag}, so a non-dev version is expected" + elif [[ "${wheel_name}" == *"pyhf-0.1.dev"* || "${wheel_name}" != *"dev"* ]]; then echo "python-build incorrectly named built distribution: ${wheel_name}" echo "python-build is lacking the history and tags required to determine version number" echo "intentionally erroring with 'exit 1' now" exit 1 - fi - else - echo "Push event to origin/main was triggered by push of tag ${latest_tag}" fi echo "python-build named built distribution: ${wheel_name}" From d0784c84216f62e4eb6ed8fe97c82bb385ed9d01 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 19:50:59 -0600 Subject: [PATCH 32/48] Use 1.2.3 as more clear SemVer --- .github/workflows/release-prepare.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index a2523a0292..ce636001f6 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: inputs: new_version: - description: 'Version of the release (e.g. 0.8.0 or 0.8.0rc1, no leading v)' + description: 'Version of the release (e.g. 1.2.3 or 1.2.3rc1, no leading v)' required: true type: string From 0a2153a6cfb01aa5049cc16545300517ed4a34ff Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 20:15:53 -0600 Subject: [PATCH 33/48] ci: Install uv with astral-sh/setup-uv * Install uv with the astral-sh/setup-uv GitHub Action in the workflows that run tooling with uv, as uv is not preinstalled on the GitHub Actions runners. c.f. https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 4 ++++ .github/workflows/release-prepare.yml | 3 +++ 2 files changed, 7 insertions(+) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 5e8a3c21bd..267f4b0696 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -71,6 +71,10 @@ jobs: id: baipp uses: hynek/build-and-inspect-python-package@2abe76da66d0a6a4a227101f9348ee855797cfa5 # v3.0.1 + - name: Install uv + if: github.event_name == 'schedule' + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + # Scoped to a dedicated build so that warnings from other tools that # build-and-inspect-python-package runs don't error the check - name: Build a sdist and wheel and check for warnings diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index ce636001f6..f98a59fd89 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -34,6 +34,9 @@ jobs: fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} + - name: Install uv + uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + # The review of the release preparation pull request validates the # release version - name: Validate version From c3893f4fb25bf60389a9161fdb5328ad57003530 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 20:21:55 -0600 Subject: [PATCH 34/48] revert name change --- .github/workflows/publish-package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 267f4b0696..6a54af0e80 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -139,7 +139,7 @@ jobs: - name: Download distribution artifact uses: actions/download-artifact@v8 with: - name: Packages + name: dist-artifact path: dist - name: List all files From a333169b56a1b4f0948a8e2de8efa98dacf52803 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 20:23:32 -0600 Subject: [PATCH 35/48] quote for clarity --- .github/workflows/release-prepare.yml | 2 +- .github/workflows/release-tag.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index f98a59fd89..05f670648f 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -22,7 +22,7 @@ jobs: run: | if [[ "${GITHUB_REF}" != "refs/heads/main" && "${GITHUB_REF}" != refs/heads/release/v* ]]; then echo "ERROR: ${GITHUB_REF} is not intended for release." >&2 - echo " Releases are made only from main or release/vX.Y.x branches." >&2 + echo " Releases are made only from 'main' or 'release/vX.Y.x' branches." >&2 exit 1 fi diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 9f2f38c32f..fcf583fef3 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -27,7 +27,7 @@ jobs: run: | if [[ "${GITHUB_REF}" != "refs/heads/main" && "${GITHUB_REF}" != refs/heads/release/v* ]]; then echo "ERROR: ${GITHUB_REF} is not intended for release." >&2 - echo " Releases are made only from main or release/vX.Y.x branches." >&2 + echo " Releases are made only from 'main' or 'release/vX.Y.x' branches." >&2 exit 1 fi From b042b45f2189fbfd2dcdba59a0348e801b505f14 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 20:47:10 -0600 Subject: [PATCH 36/48] Clarify events --- .github/workflows/publish-package.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 6a54af0e80..5a91f507e3 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -47,10 +47,6 @@ jobs: fetch-depth: 0 persist-credentials: false - # The single definition of when the built distribution gets published: - # a Git tag push deploys to TestPyPI for verification in advance of the - # release, a GitHub release publication deploys to PyPI, and a manual - # workflow dispatch can deploy a dev release snapshot to TestPyPI - name: Determine publish target id: gate if: github.repository == 'scikit-hep/pyhf' @@ -58,10 +54,13 @@ jobs: PUBLISH_INPUT: ${{ inputs.publish }} run: | target="" + # A GitHub release publication deploys to PyPI if [ "${GITHUB_EVENT_NAME}" == "release" ]; then target="pypi" + # A pushed Git tag deploys to TestPyPI for verification in advance of the release elif [ "${GITHUB_EVENT_NAME}" == "push" ] && [[ "${GITHUB_REF}" == refs/tags/v* ]]; then target="testpypi" + # A manual workflow dispatch deploys a dev release to TestPyPI elif [ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ] && [ "${PUBLISH_INPUT}" == "true" ]; then target="testpypi" fi From 9f6d243f179af5c2429bb0b6d56313dc443fe738 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 21:01:36 -0600 Subject: [PATCH 37/48] Revivse phrasing in development parge --- docs/development.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development.rst b/docs/development.rst index 5e855931f6..a52d2e6b08 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -204,9 +204,9 @@ The workflow validates that the version is newer than the current version on the selected branch and opens a release preparation pull request that bumps the version of all files defined in `tbump.toml `__ to it. -The pull request serves as the release dry run: the maintainer should verify the -new version and the diff of the bumped files, and let CI validate the changes, -before merging. +The pull request serves as the release dry run. +The maintainer should verify the new version and the diff of the bumped files and let +the CI validate the changes before merging. .. note:: From 517ef311f1d000525f05a89f3680ede27ed75bff Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 22:32:50 -0600 Subject: [PATCH 38/48] Revise terminology --- .github/workflows/release-tag.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index fcf583fef3..e27403faa9 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -20,9 +20,10 @@ jobs: name: release-tag steps: - # Fail closed in code as well, as the release-tag environment's - # protection rules live in the repository settings and GitHub - # auto-creates a referenced environment without protection rules + # Implement restrictions in the workflow as well as GitHub repository + # settings, as the release-tag environment's protection rules live in the + # repository settings and GitHub auto-creates a referenced environment + # without protection rules - name: Check branch is intended for release run: | if [[ "${GITHUB_REF}" != "refs/heads/main" && "${GITHUB_REF}" != refs/heads/release/v* ]]; then From 8cdb64b15df8dfedca5fb4f26c5be6b7012e4e15 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 22:36:46 -0600 Subject: [PATCH 39/48] Explain artifact name is 'Packages' --- .github/workflows/publish-package.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 5a91f507e3..cd0e288147 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -138,7 +138,8 @@ jobs: - name: Download distribution artifact uses: actions/download-artifact@v8 with: - name: dist-artifact + # artifact name from hynek/build-and-inspect-python-package + name: Packages path: dist - name: List all files From eed7b7af5f0bf0ef7d2b394851ddd307c80588ee Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 23:16:29 -0600 Subject: [PATCH 40/48] ci: Pin pypa/gh-action-pypi-publish action to commit SHA * Pin the pypa/gh-action-pypi-publish steps to an immutable commit SHA, as version tags are mutable and the publish job holds the PyPI trusted publishing OIDC credential. Dependabot keeps SHA pinned actions updated through the version comment. * Pinning the remaining version tag pinned GitHub Actions is deferred to a separate pull request. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index cd0e288147..f9fd4032e3 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -159,13 +159,13 @@ jobs: - name: Publish distribution to Test PyPI if: needs.build.outputs.publish-target == 'testpypi' - uses: pypa/gh-action-pypi-publish@v1.14.2 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: repository-url: https://test.pypi.org/legacy/ print-hash: true - name: Publish distribution to PyPI if: needs.build.outputs.publish-target == 'pypi' - uses: pypa/gh-action-pypi-publish@v1.14.2 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: print-hash: true From d78f86038b906d86cdea7fe09cbeea58a3436f09 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Tue, 11 Aug 2026 23:16:30 -0600 Subject: [PATCH 41/48] ci: Serialize release preparation runs by version * Serialize Prepare release workflow runs for the same release version with a concurrency group keyed on the version input. Concurrent runs for the same version, e.g. dispatched on different branches, would otherwise race force pushing the same bump-version/vX.Y.Z branch. Runs preparing different versions remain independent, and queued runs are not cancelled. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 05f670648f..cbf2450c8c 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -8,6 +8,13 @@ on: required: true type: string +concurrency: + # Serialize runs preparing the same release version, as concurrent runs + # (e.g. dispatched on different branches) force push the same + # bump-version/vX.Y.Z branch + group: ${{ github.workflow }}-${{ inputs.new_version }} + cancel-in-progress: false + permissions: contents: read From 7c14f539ea11f75b0ab781210c855d403b42c10c Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Thu, 13 Aug 2026 15:46:10 -0600 Subject: [PATCH 42/48] fix: Apply zizmor fixes --- .github/workflows/publish-package.yml | 2 ++ .github/workflows/release-prepare.yml | 1 + .github/workflows/release-tag.yml | 1 + 3 files changed, 4 insertions(+) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index f9fd4032e3..4d8518f7cd 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -73,6 +73,8 @@ jobs: - name: Install uv if: github.event_name == 'schedule' uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + enable-cache: false # Scoped to a dedicated build so that warnings from other tools that # build-and-inspect-python-package runs don't error the check diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index cbf2450c8c..afbe02baab 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -40,6 +40,7 @@ jobs: with: fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} + persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index e27403faa9..2e96725dd7 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -39,6 +39,7 @@ jobs: with: fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} + persist-credentials: false - name: Read version from tbump.toml id: version From e6b1ad4ae3642d916fb881378c4e54158836ee0d Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Thu, 13 Aug 2026 15:49:25 -0600 Subject: [PATCH 43/48] chore: Move validation script to 'ci/' directory * keep .github/ for workflow specific files. --- .github/workflows/release-prepare.yml | 2 +- {.github/workflows => ci}/validate-version.py | 0 pyproject.toml | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename {.github/workflows => ci}/validate-version.py (100%) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index afbe02baab..1531862871 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -51,7 +51,7 @@ jobs: env: VERSION: ${{ inputs.new_version }} run: | - uv run --no-project .github/workflows/validate-version.py "${VERSION}" + uv run --no-project ci/validate-version.py "${VERSION}" # Catch versions already released from another release series branch # (fetch-depth: 0 fetches all tags, including release branch tags) diff --git a/.github/workflows/validate-version.py b/ci/validate-version.py similarity index 100% rename from .github/workflows/validate-version.py rename to ci/validate-version.py diff --git a/pyproject.toml b/pyproject.toml index c8b308c9f9..a3178f9530 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -314,7 +314,7 @@ typing-modules = ["pyhf.typing"] flake8-tidy-imports.ban-relative-imports = "all" [tool.ruff.lint.per-file-ignores] -".github/workflows/validate-version.py" = ["T20"] +"ci/validate-version.py" = ["T20"] "docs/jupyterlite/jupyterlite.py" = ["F401", "F704"] "**.ipynb" = ["T20"] "noxfile.py" = ["T20"] From b2661307120c4339e0993d41e5306f5b0c507b12 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Fri, 14 Aug 2026 00:35:09 -0600 Subject: [PATCH 44/48] ci: Pin actions/checkout action to commit SHA * Pin the actions/checkout steps in the release process workflows to an immutable commit SHA, as version tags are mutable and these workflows handle release credentials. Dependabot keeps SHA pinned actions updated through the version comment. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/publish-package.yml | 2 +- .github/workflows/release-prepare.yml | 2 +- .github/workflows/release-tag.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 4d8518f7cd..979eca50d8 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -42,7 +42,7 @@ jobs: publish-target: ${{ steps.gate.outputs.target }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 1531862871..5a7acc2657 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -36,7 +36,7 @@ jobs: # The workflow dispatch ref selects the branch to release from. # Use GitHub PAT to authenticate so CI triggers on the pull request. - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index 2e96725dd7..f5ce5ca676 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -35,7 +35,7 @@ jobs: # Use GitHub PAT to authenticate so the tag push triggers the publishing # and Docker workflows - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} From 1988c547e24f7f54fabcd6bd96f31e7e1d436e64 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Fri, 14 Aug 2026 00:39:05 -0600 Subject: [PATCH 45/48] ci: Restore checkout credential persistence for release pushes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Restore the persisted checkout credentials in the Prepare release and Tag release workflows, as their 'git push' steps authenticate with the credentials that actions/checkout configures — with persist-credentials disabled the pushes fail unauthenticated. The publish workflow checkout keeps persist-credentials disabled, as it never pushes. * Ignore zizmor's artipacked audit for these two workflows, as the persisted PAT is required for the pushes. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 1 - .github/workflows/release-tag.yml | 1 - .github/zizmor.yml | 7 +++++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 5a7acc2657..83823293ce 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -40,7 +40,6 @@ jobs: with: fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} - persist-credentials: false - name: Install uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index f5ce5ca676..e425a0cbd2 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -39,7 +39,6 @@ jobs: with: fetch-depth: 0 token: ${{ secrets.ACCESS_TOKEN }} - persist-credentials: false - name: Read version from tbump.toml id: version diff --git a/.github/zizmor.yml b/.github/zizmor.yml index 690b782eea..785cfd46b7 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -8,3 +8,10 @@ rules: concurrency-limits: ignore: - copilot-setup-steps.yml + + artipacked: + # The release workflows push a branch or tag with the credentials that + # actions/checkout persists, so credential persistence is required + ignore: + - release-prepare.yml + - release-tag.yml From 8df459b2ae28bb23d3f40147276c7a2912283826 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Fri, 14 Aug 2026 00:44:56 -0600 Subject: [PATCH 46/48] ci: Include base branch in release preparation branch name * Include the base branch in the release preparation branch name (bump-version/BASE_BRANCH/vX.Y.Z) so that each release branch has its own release preparation branch and pull request. With a shared branch name, dispatching the same version from a second base branch while the first release preparation pull request was still open would force push over it and update the pull request against the first base branch, instead of opening a pull request against the intended one. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 83823293ce..13f8a5b0a5 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -72,7 +72,9 @@ jobs: VERSION: ${{ inputs.new_version }} BASE_BRANCH: ${{ github.ref_name }} run: | - branch="bump-version/v${VERSION}" + # Include the base branch in the branch name so that each release + # branch has its own release preparation branch and pull request + branch="bump-version/${BASE_BRANCH}/v${VERSION}" git config --local user.name "github-actions[bot]" git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" From 43de48588077bd976c85c696a3245e25b58d0179 Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Fri, 14 Aug 2026 01:08:59 -0600 Subject: [PATCH 47/48] ci: Gate release preparation with the release-prepare environment * Run the Prepare release workflow job in the release-prepare GitHub Actions environment so that its required reviewers approve runs, its deployment branch policy restricts the branches, and the ACCESS_TOKEN secret can be scoped to the environment instead of being readable by any workflow as a repository level secret. Assisted-by: ClaudeCode:claude-fable-5 --- .github/workflows/release-prepare.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml index 13f8a5b0a5..b3a90eab6e 100644 --- a/.github/workflows/release-prepare.yml +++ b/.github/workflows/release-prepare.yml @@ -23,6 +23,10 @@ jobs: name: Open release preparation pull request runs-on: ubuntu-latest if: github.repository == 'scikit-hep/pyhf' + # Required reviewers, the allowed branches, and access to the ACCESS_TOKEN + # environment secret are enforced through the environment's protection rules + environment: + name: release-prepare steps: - name: Check branch is intended for release From 93d5ae3bce459ddaa99fc035cc65b21a1e8ababa Mon Sep 17 00:00:00 2001 From: Matthew Feickert Date: Fri, 14 Aug 2026 01:09:01 -0600 Subject: [PATCH 48/48] docs: Document release environment secret scoping * Document that both the release-prepare and release-tag GitHub Actions environments must be configured with required reviewers and restricted deployment branches, and that the ACCESS_TOKEN secret is stored as an environment secret in them instead of as a repository level secret, so that only approved release workflow runs can access it. Assisted-by: ClaudeCode:claude-fable-5 --- docs/development.rst | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/development.rst b/docs/development.rst index a52d2e6b08..d730ca7b9b 100644 --- a/docs/development.rst +++ b/docs/development.rst @@ -227,12 +227,18 @@ The workflow requires approval through the ``release-tag`` GitHub Actions environment, and then creates an annotated tag for the version defined in ``tbump.toml`` and pushes the tag to the release branch. -The ``release-tag`` `GitHub Actions environment +The ``release-prepare`` and ``release-tag`` `GitHub Actions environments `__ must be configured in the repository settings with the maintainers as required reviewers and with the deployment branches restricted to ``main`` and ``release/v*``, as GitHub creates a referenced environment without any protection rules. +The ``ACCESS_TOKEN`` secret used to push the release preparation pull request +and the release tag is stored as an environment secret in both environments, +not as a repository level secret, so that only workflow runs approved by the +required reviewers can access it. +Additionally, every deployment workflow approval is now recorded in the +environment's deployment history which gives an audit history. If the release workflows are not available on the release branch (e.g. historic release branches) a maintainer can perform the same steps locally by bumping the