diff --git a/.github/workflows/python-publish.yml b/.github/workflows/python-publish.yml index 0a695f1..84c11ce 100644 --- a/.github/workflows/python-publish.yml +++ b/.github/workflows/python-publish.yml @@ -1,4 +1,4 @@ -name: Build, verify, and publish Python package +name: Build and verify Python package on: workflow_call: @@ -11,11 +11,6 @@ on: description: Expected package version without the v prefix required: true type: string - publish: - description: Publish the verified artifacts to PyPI - required: false - default: false - type: boolean permissions: contents: read @@ -105,24 +100,3 @@ jobs: artifact-sha256.txt if-no-files-found: error retention-days: 7 - - publish: - name: Publish verified artifacts to PyPI - if: inputs.publish - needs: build - runs-on: ubuntu-latest - permissions: - id-token: write - steps: - - name: Download verified artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: python-package-${{ inputs.ref }} - - - name: Verify retained artifact checksums - run: sha256sum --check artifact-sha256.txt - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - with: - skip-existing: true diff --git a/.github/workflows/release-on-merge.yml b/.github/workflows/release-on-merge.yml index 6e06eb5..e8410d4 100644 --- a/.github/workflows/release-on-merge.yml +++ b/.github/workflows/release-on-merge.yml @@ -7,9 +7,14 @@ on: workflow_dispatch: inputs: pr_number: - description: Historical merged release PR to validate without publishing + description: Historical merged release PR to validate or recover required: true type: string + recover_pypi_publication: + description: Publish a validated historical release whose PyPI upload failed + required: false + default: false + type: boolean permissions: contents: read @@ -74,7 +79,7 @@ jobs: --github-output "$GITHUB_OUTPUT" - name: Summarize dry run - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && !inputs.recover_pypi_publication env: MERGE_SHA: ${{ steps.validate.outputs.merge_sha }} TAG: ${{ steps.validate.outputs.tag }} @@ -87,7 +92,7 @@ jobs: release: name: Create or verify tag and GitHub Release - if: github.event_name == 'pull_request' + if: github.event_name == 'pull_request' || inputs.recover_pypi_publication needs: validate runs-on: ubuntu-latest permissions: @@ -136,28 +141,45 @@ jobs: fi echo "url=$url" >> "$GITHUB_OUTPUT" - publish-production: - name: Test, build, and publish - if: github.event_name == 'pull_request' + build-production: + name: Test and build release artifacts + if: github.event_name == 'pull_request' || inputs.recover_pypi_publication needs: [validate, release] permissions: contents: read - id-token: write uses: ./.github/workflows/python-publish.yml with: ref: ${{ needs.validate.outputs.merge_sha }} version: ${{ needs.validate.outputs.version }} - publish: true + + publish-production: + name: Publish verified artifacts to PyPI + if: github.event_name == 'pull_request' || inputs.recover_pypi_publication + needs: [validate, release, build-production] + runs-on: ubuntu-latest + permissions: + id-token: write + steps: + - name: Download verified artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: python-package-${{ needs.validate.outputs.merge_sha }} + + - name: Verify retained artifact checksums + run: sha256sum --check artifact-sha256.txt + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 + with: + skip-existing: true verify-dry-run: name: Test and build without publishing - if: github.event_name == 'workflow_dispatch' + if: github.event_name == 'workflow_dispatch' && !inputs.recover_pypi_publication needs: validate permissions: contents: read - id-token: write uses: ./.github/workflows/python-publish.yml with: ref: ${{ needs.validate.outputs.merge_sha }} version: ${{ needs.validate.outputs.version }} - publish: false diff --git a/AGENTS.md b/AGENTS.md index 1d3d495..81369f2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,9 +75,9 @@ This collection-time filtering replaced 1,700 runtime skips (1,655 out-of-range Use `s/version++ [patch|minor|major]` to prepare a release version bump; it defaults to `patch`. The script is optional, but it is the canonical automated path: from a clean and up-to-date `live` branch it runs the full suite, creates `release/vX.Y.Z`, synchronizes `pyproject.toml` and `CITATION.cff`, validates the package build, commits `chore(release): vX.Y.Z`, pushes the branch, and opens a PR. Use `--dry-run` to preview without changing anything. -The script deliberately does not tag or publish because `live` is protected and the release tag must point to the reviewed PR's exact merge commit. Review CI and merge the release PR using a merge commit; that merge is the final human release action. `.github/workflows/release-on-merge.yml` validates the PR and exact merge commit, creates or reuses the annotated tag and GitHub Release without overwriting conflicts, and directly invokes the reusable `.github/workflows/python-publish.yml` path. Do not push directly to `live`, manually create release tags, or use squash/rebase merging for release PRs. See [`s/README.md`](s/README.md) for usage and the historical non-publishing dry run. +The script deliberately does not tag or publish because `live` is protected and the release tag must point to the reviewed PR's exact merge commit. Review CI and merge the release PR using a merge commit; that merge is the final human release action. `.github/workflows/release-on-merge.yml` validates the PR and exact merge commit, creates or reuses the annotated tag and GitHub Release without overwriting conflicts, invokes `.github/workflows/python-publish.yml` to test and build, and publishes the verified artifacts from its own top-level job. Do not push directly to `live`, manually create release tags, or use squash/rebase merging for release PRs. See [`s/README.md`](s/README.md) for usage and the historical dry-run/recovery dispatch. -Release PRs may change only `pyproject.toml` and `CITATION.cff`, must carry exactly one supported patch, minor, or major bump, and must be merged from the same repository into `live` with a merge commit. Repository settings may disable squash/rebase globally, but the release workflow enforces merge-commit policy independently. Keep PyPI Trusted Publishing bound to `.github/workflows/python-publish.yml`; introducing a protected environment requires a coordinated PyPI trusted-publisher update. +Release PRs may change only `pyproject.toml` and `CITATION.cff`, must carry exactly one supported patch, minor, or major bump, and must be merged from the same repository into `live` with a merge commit. Repository settings may disable squash/rebase globally, but the release workflow enforces merge-commit policy independently. Keep PyPI Trusted Publishing bound to `.github/workflows/release-on-merge.yml`; PyPI does not support a nested reusable workflow as the trusted identity. Introducing a protected environment requires a coordinated PyPI trusted-publisher update. ## Key Code Locations diff --git a/rcpchgrowth/_build_info.py b/rcpchgrowth/_build_info.py index 813560d..a18e50e 100644 --- a/rcpchgrowth/_build_info.py +++ b/rcpchgrowth/_build_info.py @@ -1,7 +1,7 @@ """Build-time revision metadata. `COMMIT` is deliberately `"unknown"` in checked-out source, editable installs, -and any build that has not been stamped. The PyPI publish workflow +and any build that has not been stamped. The release package workflow (`.github/workflows/python-publish.yml`) overwrites this file with the release commit SHA immediately before building the distribution, so that released wheels/sdists report a real value via `Measurement.measurement` diff --git a/s/README.md b/s/README.md index 74aaad5..f625b72 100644 --- a/s/README.md +++ b/s/README.md @@ -30,6 +30,6 @@ The `s/` directory contains convenience scripts that standardise repeated develo - `s/version++ major` - prepare a major bump. - `s/version++ minor --dry-run` - preview the proposed version and workflow without changing anything. -The script does not tag or publish. Review CI and merge the release PR using a merge commit; that merge is the final human release action. `.github/workflows/release-on-merge.yml` then validates the same-repository PR, its permitted files, supported version bump, synchronized metadata, and exact two-parent merge commit before creating or reusing the annotated tag and GitHub Release. It directly invokes `.github/workflows/python-publish.yml`, avoiding `GITHUB_TOKEN` event anti-recursion, and publishes only the exact commit-stamped artifacts that passed package checks and an isolated installed-wheel smoke test. +The script does not tag or publish. Review CI and merge the release PR using a merge commit; that merge is the final human release action. `.github/workflows/release-on-merge.yml` then validates the same-repository PR, its permitted files, supported version bump, synchronized metadata, and exact two-parent merge commit before creating or reusing the annotated tag and GitHub Release. It invokes `.github/workflows/python-publish.yml` to test and build once, then publishes the verified commit-stamped artifacts from the top-level workflow so PyPI Trusted Publishing and attestations use the supported workflow identity. -To exercise the complete validation, Python 3.10-3.13 test matrix, build, and artifact checks without creating a tag, GitHub Release, or PyPI publication, manually run the `Release merged version PR` workflow with the number of a merged release PR. Manual dispatch is always non-publishing. The first end-to-end historical dispatch can only run after this workflow has merged to `live`; use a release PR whose exact commit satisfies the current quality gates. +To exercise the complete validation, Python 3.10-3.13 test matrix, build, and artifact checks without creating a tag, GitHub Release, or PyPI publication, manually run the `Release merged version PR` workflow with the number of a merged release PR. Leave `recover_pypi_publication` disabled for this dry run. Enable it only to recover a validated release whose automatic PyPI upload failed; the workflow then verifies or reuses the existing tag and GitHub Release before rebuilding and publishing the exact merge commit. Use a release PR whose exact commit satisfies the current quality gates. diff --git a/spec/queries.md b/spec/queries.md index f46ebe8..bf15561 100644 --- a/spec/queries.md +++ b/spec/queries.md @@ -20,6 +20,6 @@ PR #101 contains the existing fix for exact 23-, 25-, and 42-week chart boundari The automated cascade can require release PRs to be merged with a merge commit and can reject other merge strategies at runtime. Should GitHub repository settings also disable rebase and squash merging for release branches, or is workflow enforcement sufficient? -Existing releases prove that PyPI Trusted Publishing accepts `.github/workflows/python-publish.yml` without a GitHub environment. The implementation will preserve that workflow identity. Confirm whether a protected `pypi` environment should be introduced later; adding one requires updating PyPI's trusted-publisher configuration and any required environment reviewer would add a second human intervention after merge. +The v4.6.4 release proved that PyPI does not support `.github/workflows/python-publish.yml` as a nested reusable Trusted Publisher identity: token exchange or artifact attestation verification fails because GitHub identifies `.github/workflows/release-on-merge.yml` as the top-level workflow. Publishing therefore runs in the top-level workflow, while the reusable workflow retains the test and single-build gates. Confirm whether a protected `pypi` environment should be introduced later; adding one requires updating PyPI's trusted-publisher configuration and any required environment reviewer would add a second human intervention after merge. -R24 enforces same-repository release branches and merge commits at runtime, so repository-wide disabling of squash and rebase remains optional defence in depth rather than a release prerequisite. It preserves the existing environment-free Trusted Publishing identity; introducing a protected `pypi` environment remains a separate coordinated policy change because it requires updating PyPI and could add another human action after merge. +R24 enforces same-repository release branches and merge commits at runtime, so repository-wide disabling of squash and rebase remains optional defence in depth rather than a release prerequisite. PyPI Trusted Publishing is bound to the environment-free top-level `.github/workflows/release-on-merge.yml`; introducing a protected `pypi` environment remains a separate coordinated policy change because it requires updating PyPI and could add another human action after merge. diff --git a/spec/roadmap.md b/spec/roadmap.md index f633488..8ad2781 100644 --- a/spec/roadmap.md +++ b/spec/roadmap.md @@ -33,7 +33,7 @@ Complete these items before broad formatting, import cleanup, packaging reductio - [ ] **R1 - Resolve the broken public `create_thrive_lines` API.** `rcpchgrowth/dynamic_growth.py:226-294` uses undefined `plt`, has its output-producing plotting code commented out, and returns nothing, while `rcpchgrowth/__init__.py:8` exports it publicly. Decide whether plotting remains supported; either restore it with an optional matplotlib dependency and tests, or remove the export through an explicitly managed breaking change. - [x] **R2 - Restore executable notebooks and expose package version metadata consistently.** The package root and measurement provenance now share `rcpchgrowth.__version__`, all four notebooks execute from fresh kernels, and Python 3.13 CI executes them headlessly. -- [x] **R24 - Automate the post-merge release cascade.** A same-repository `release/vX.Y.Z` PR merged into `live` with a merge commit is validated against its exact files, parent commits, supported bump, and synchronized metadata before CI creates or reuses the annotated tag and GitHub Release. The cascade directly calls the least-privilege reusable PyPI workflow, which retains the Python 3.10-3.13 gate and publishes the one exact commit-stamped artifact set that passed checks and installed-wheel smoke tests. `workflow_dispatch` accepts a merged release PR number and runs the same validation, test, build, and artifact checks without tag, Release, or PyPI mutation; the first end-to-end historical dispatch can only run after the workflow merges to `live`. +- [x] **R24 - Automate the post-merge release cascade.** A same-repository `release/vX.Y.Z` PR merged into `live` with a merge commit is validated against its exact files, parent commits, supported bump, and synchronized metadata before CI creates or reuses the annotated tag and GitHub Release. The cascade calls the least-privilege reusable package workflow for the Python 3.10-3.13 gate and one exact commit-stamped artifact build, then publishes those verified artifacts from the top-level workflow identity supported by PyPI Trusted Publishing. `workflow_dispatch` accepts a merged release PR number for a non-publishing validation/build dry run or explicit recovery of a failed PyPI publication. ## Medium priority