Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 1 addition & 27 deletions .github/workflows/python-publish.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Build, verify, and publish Python package
name: Build and verify Python package

on:
workflow_call:
Expand All @@ -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
Expand Down Expand Up @@ -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
44 changes: 33 additions & 11 deletions .github/workflows/release-on-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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:
Expand Down Expand Up @@ -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
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion rcpchgrowth/_build_info.py
Original file line number Diff line number Diff line change
@@ -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`
Expand Down
4 changes: 2 additions & 2 deletions s/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 2 additions & 2 deletions spec/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
2 changes: 1 addition & 1 deletion spec/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down