diff --git a/.github/ISSUE_TEMPLATE/~release-checklist.md b/.github/ISSUE_TEMPLATE/~release-checklist.md index d10f2fbd25..c05145fb4e 100644 --- a/.github/ISSUE_TEMPLATE/~release-checklist.md +++ b/.github/ISSUE_TEMPLATE/~release-checklist.md @@ -23,20 +23,17 @@ 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. +* [ ] 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. -* [ ] Verify the release tag commit has bumped the correct versions. * [ ] 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. - [ ] 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. @@ -46,15 +43,15 @@ For a video walkthrough consult the [``pyhf`` ``v0.7.1`` release recording](http * [ ] 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. -* [ ] 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. +* [ ] 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). * [ ] 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/.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 }} diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 711ff6e563..979eca50d8 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: @@ -35,95 +38,95 @@ jobs: permissions: id-token: write attestations: write + outputs: + 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 - - name: Set up Python - uses: actions/setup-python@v7 - with: - python-version: '3.14' - - - name: Install python-build and twine + - 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="" + # 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 + echo "target=${target}" >> "$GITHUB_OUTPUT" - name: Build a sdist and wheel - if: github.event_name != 'schedule' - run: | - python -m build --installer uv . + 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 + 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 - 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 . + env: + 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/')" + 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/ -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 + wheel_name=$(find "${DIST_PATH}" -iname "*.whl" -printf "%f\n") + # 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 'return 1' now" - return 1 - fi - else - echo "Push event to origin/main was triggered by push of tag ${latest_tag}" + echo "intentionally erroring with 'exit 1' now" + exit 1 fi echo "python-build named built distribution: ${wheel_name}" - 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' needs: build + 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/ @@ -137,45 +140,34 @@ 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 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 }} - - - 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 + REPOSITORY: ${{ github.repository }} + run: gh attestation verify dist/pyhf-*.whl --repo "${REPOSITORY}" + + - name: Publish distribution to Test PyPI + if: needs.build.outputs.publish-target == 'testpypi' + 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: github.event_name == 'release' && github.event.action == 'published' && github.repository == 'scikit-hep/pyhf' - uses: pypa/gh-action-pypi-publish@v1.14.2 + - name: Publish distribution to PyPI + if: needs.build.outputs.publish-target == 'pypi' + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 with: print-hash: true diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 0000000000..b3a90eab6e --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,113 @@ +name: Prepare release + +on: + workflow_dispatch: + inputs: + new_version: + description: 'Version of the release (e.g. 1.2.3 or 1.2.3rc1, no leading v)' + 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 + +jobs: + prepare: + 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 + 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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + 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 + env: + VERSION: ${{ inputs.new_version }} + run: | + 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) + 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 + env: + VERSION: ${{ inputs.new_version }} + run: uvx tbump --non-interactive --only-patch "${VERSION}" + + - name: Open release preparation pull request + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + VERSION: ${{ inputs.new_version }} + BASE_BRANCH: ${{ github.ref_name }} + run: | + # 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" + + git switch --create "${branch}" + git commit --all --message "chore: Bump version to v${VERSION}" + # 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" <&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 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + 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" + + # 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: | + 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 + + 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 "pyhf v${VERSION}" + + git tag -n99 --list "v${VERSION}" + + - name: Push tag to GitHub + env: + VERSION: ${{ steps.version.outputs.version }} + run: git push origin "v${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 diff --git a/ci/validate-version.py b/ci/validate-version.py new file mode 100644 index 0000000000..39b5daca3a --- /dev/null +++ b/ci/validate-version.py @@ -0,0 +1,57 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["packaging"] +# /// +"""Validate that a release version is newer than the current release version.""" + +import argparse +import re +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: + 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 tbump.toml release" + " version format X.Y.Z or X.Y.ZrcN (with no leading v)." + ) + 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) + + 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" + 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/docs/development.rst b/docs/development.rst index 9cf3cee2f3..d730ca7b9b 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,100 @@ 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). +* 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 +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 +the 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 +~~~~~~~~~~~~~~~~~ + +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`` and pushes the tag to the release branch. + +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 +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 + +Release Branches +~~~~~~~~~~~~~~~~ + +Each minor release series has a corresponding release branch, named +``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 +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 ``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 +`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 ~~~~~~~~~~ @@ -272,6 +344,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/ diff --git a/pyproject.toml b/pyproject.toml index ebd5620b34..a3178f9530 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] +"ci/validate-version.py" = ["T20"] "docs/jupyterlite/jupyterlite.py" = ["F401", "F704"] "**.ipynb" = ["T20"] "noxfile.py" = ["T20"]