Skip to content

Implement gated release pipeline with CI enforcement - #18

Merged
hasanzakeri merged 1 commit into
mainfrom
ci/release-workflow
Apr 30, 2026
Merged

hasanzakeri merged 1 commit into
mainfrom
ci/release-workflow

Conversation

@hasanzakeri

Copy link
Copy Markdown
Owner

This pull request introduces a robust and secure release process by adding new workflows and documentation to enforce stricter controls over how releases are created and published. The changes ensure that releases are only made from well-tested code, prevent accidental or unauthorized modifications to the release branch, and provide clear instructions for maintainers.

Key changes include:

Release Process Enforcement:

  • Added a release-source-gate job to .github/workflows/ci.yml to ensure that pull requests to the release branch can only come from the main branch, blocking all other sources.
  • Introduced a new .github/workflows/release.yml workflow, which:
    • Ensures release tags only point to the tip of the release branch via a gating job.
    • Builds and uploads Python wheels for Linux, macOS, and Windows (across multiple architectures and Python versions), as well as a source distribution.
    • Publishes artifacts to PyPI using trusted publishing with OIDC, and only after all builds succeed.

Documentation:

  • Added a comprehensive RELEASING.md file detailing the release process, the guarantees provided by the new checks and workflows, and troubleshooting steps for common issues.

  New `release.yml`: tag-push (`v*`) builds wheels for Linux x86_64/aarch64,
  macOS x86_64/aarch64, and Windows x64 across CPython 3.11/3.12/3.13, plus
  an sdist. The `publish` job uploads to PyPI via OIDC trusted publishing
  and uses `skip-existing` so re-runs are idempotent.

  The `gate` job rejects any tag whose SHA isn't the current tip of
  `release`. Combined with branch protection requiring `ci` to pass on
  `release`, that turns "tip of release" into a hard guarantee that full
  CI (Tier 1 + Tier 2) ran on the exact SHA being released — so the
  release workflow doesn't re-run tests itself.

  Add a `release-source-gate` job to `ci.yml` that fails any PR targeting
  `release` whose head ref isn't `main`. GitHub branch protection has no
  "restrict source branch" setting, so we enforce it as a required check.

  `RELEASING.md` documents guarantees (branch protection rules,
  PyPI trusted publisher) and the per-release procedure, plus recovery
  from a lost tag race.
@hasanzakeri
hasanzakeri requested a review from Copilot April 30, 2026 08:29
@hasanzakeri
hasanzakeri merged commit 7bdd46e into main Apr 30, 2026
5 checks passed
@hasanzakeri
hasanzakeri deleted the ci/release-workflow branch April 30, 2026 08:32

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a gated release pipeline that constrains how the release branch is updated and how PyPI releases are built/published, plus documentation describing the new process and guarantees.

Changes:

  • Adds a release-source-gate CI job to only allow PRs into release from main.
  • Introduces a release.yml workflow to gate tags to the tip of release, build wheels/sdist, and publish to PyPI via OIDC trusted publishing.
  • Adds RELEASING.md documenting the end-to-end release procedure and troubleshooting steps.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
RELEASING.md Documents the enforced release flow, guarantees, and common failure modes.
.github/workflows/release.yml Implements tag-to-release-tip gating, multi-OS wheel builds, sdist build, and PyPI publish via OIDC.
.github/workflows/ci.yml Adds a CI enforcement job to restrict the source branch for PRs targeting release.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread .github/workflows/ci.yml
Comment on lines +26 to +29
- name: PRs to release must come from main
run: |
if [ "$GITHUB_HEAD_REF" != "main" ]; then
echo "::error::PRs to release must come from main, got '$GITHUB_HEAD_REF'."

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

release-source-gate only checks $GITHUB_HEAD_REF == "main". A PR from a fork whose branch is also named main will pass this gate even though it did not originate from this repo’s main. If the intent is to ensure the source is this repository’s main, also validate the head repo (e.g., github.event.pull_request.head.repo.full_name == github.repository) in addition to the branch name.

Suggested change
- name: PRs to release must come from main
run: |
if [ "$GITHUB_HEAD_REF" != "main" ]; then
echo "::error::PRs to release must come from main, got '$GITHUB_HEAD_REF'."
- name: PRs to release must come from this repo's main
env:
HEAD_REPO_FULL_NAME: ${{ github.event.pull_request.head.repo.full_name }}
THIS_REPO: ${{ github.repository }}
run: |
if [ "$GITHUB_HEAD_REF" != "main" ] || [ "$HEAD_REPO_FULL_NAME" != "$THIS_REPO" ]; then
echo "::error::PRs to release must come from ${THIS_REPO}'s main branch, got '${HEAD_REPO_FULL_NAME}:${GITHUB_HEAD_REF}'."

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +61
target: [x86_64, aarch64]
python-version: ['3.11', '3.12', '3.13']
include:
- target: x86_64
runner: macos-13
- target: aarch64
runner: macos-14

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The macos job matrix is malformed: include entries only specify target/runner, which adds extra matrix rows without python-version, and the main 6 combinations won’t have runner set. As a result runs-on: ${{ matrix.runner }} will be empty/undefined for most rows and the job will fail to start. Define runner for every matrix combination (e.g., compute it from matrix.target, or include python-version in each include entry).

Suggested change
target: [x86_64, aarch64]
python-version: ['3.11', '3.12', '3.13']
include:
- target: x86_64
runner: macos-13
- target: aarch64
runner: macos-14
include:
- target: x86_64
python-version: '3.11'
runner: macos-13
- target: x86_64
python-version: '3.12'
runner: macos-13
- target: x86_64
python-version: '3.13'
runner: macos-13
- target: aarch64
python-version: '3.11'
runner: macos-14
- target: aarch64
python-version: '3.12'
runner: macos-14
- target: aarch64
python-version: '3.13'
runner: macos-14

Copilot uses AI. Check for mistakes.
Comment thread RELEASING.md
Comment on lines +5 to +6
`release`. The `release.yml` workflow gates on tag-equals-release-tip, so
the SHA being released has demonstrably passed full CI.

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This intro says release.yml “gates on tag-equals-release-tip”, but the workflow/job name is gate and the rest of this doc refers to the gate job. Please align the wording so maintainers can easily map the documentation to the actual workflow/job names.

Suggested change
`release`. The `release.yml` workflow gates on tag-equals-release-tip, so
the SHA being released has demonstrably passed full CI.
`release`. The `release.yml` workflow's `gate` job ensures the tag points
to the tip of `release`, so the SHA being released has demonstrably passed
full CI.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants