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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ concurrency:
cancel-in-progress: true

jobs:
release-source-gate:
# PRs targeting `release` are only allowed from `main`. GitHub branch
# protection has no "restrict source branch" setting, so we enforce it
# here as a required check. Make this a required status check on the
# `release` branch so the PR can't merge without it.
if: github.event_name == 'pull_request' && github.base_ref == 'release'
runs-on: ubuntu-latest
steps:
- 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'."
Comment on lines +26 to +29

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.
exit 1
fi

ci:
runs-on: ubuntu-latest
steps:
Expand Down
143 changes: 143 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
name: Release

on:
push:
tags: ['v*']
workflow_dispatch:

permissions:
contents: read

jobs:
gate:
# Enforce that the tag points to the current tip of `release`. The
# release-branch CI (ci.yml) runs Tier 1 + Tier 2 on every push to
# release, so tip-of-release is the SHA on which full CI passed.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Tag must point to the tip of release
run: |
git fetch origin release --depth=1
expected=$(git rev-parse FETCH_HEAD)
if [ "$GITHUB_SHA" != "$expected" ]; then
echo "::error::Tag $GITHUB_REF_NAME ($GITHUB_SHA) is not the tip of release ($expected). See RELEASING.md."
exit 1
fi

linux:
runs-on: ubuntu-latest
needs: gate
strategy:
fail-fast: false
matrix:
target: [x86_64, aarch64]
steps:
- uses: actions/checkout@v6

- uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
manylinux: auto
args: --release --out dist -i python3.11 -i python3.12 -i python3.13

- uses: actions/upload-artifact@v4
with:
name: wheels-linux-${{ matrix.target }}
path: dist

macos:
needs: gate
strategy:
fail-fast: false
matrix:
target: [x86_64, aarch64]
python-version: ['3.11', '3.12', '3.13']
include:
- target: x86_64
runner: macos-13
- target: aarch64
runner: macos-14
Comment on lines +55 to +61

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.
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}

- uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist -i python${{ matrix.python-version }}

- uses: actions/upload-artifact@v4
with:
name: wheels-macos-${{ matrix.target }}-py${{ matrix.python-version }}
path: dist

windows:
runs-on: windows-latest
needs: gate
strategy:
fail-fast: false
matrix:
target: [x64]
python-version: ['3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v6

- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
architecture: ${{ matrix.target }}

- uses: PyO3/maturin-action@v1
with:
target: ${{ matrix.target }}
args: --release --out dist

- uses: actions/upload-artifact@v4
with:
name: wheels-windows-${{ matrix.target }}-py${{ matrix.python-version }}
path: dist

sdist:
runs-on: ubuntu-latest
needs: gate
steps:
- uses: actions/checkout@v6

- uses: PyO3/maturin-action@v1
with:
command: sdist
args: --out dist

- uses: actions/upload-artifact@v4
with:
name: sdist
path: dist

publish:
name: Publish to PyPI
runs-on: ubuntu-latest
needs: [linux, macos, windows, sdist]
if: startsWith(github.ref, 'refs/tags/v')
environment:
name: pypi
url: https://pypi.org/p/pyharfrust
permissions:
# Required for PyPI trusted publishing (OIDC). Configure the publisher
# at https://pypi.org/manage/account/publishing/ before tagging a release.
id-token: write
steps:
- uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true

- uses: pypa/gh-action-pypi-publish@release/v1
with:
packages-dir: dist
skip-existing: true
57 changes: 57 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Releasing

Releases are cut from the `release` branch. The `ci.yml` workflow runs full
CI (Tier 1 + Tier 2 against the pinned harfrust corpus) on every push to
`release`. The `release.yml` workflow gates on tag-equals-release-tip, so
the SHA being released has demonstrably passed full CI.
Comment on lines +5 to +6

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.

## Guarantees in place

- **Branch protection on `release`** — direct pushes blocked, all commits
must come through a PR with the `ci` and `release-source-gate` checks
passing, branch must be up to date with base, no force pushes.
- **`release-source-gate` job (`ci.yml`)** — rejects any PR targeting
`release` whose head ref isn't `main`. Required check.
- **`gate` job (`release.yml`)** — rejects any tag whose SHA isn't the
current tip of `release`. Together with the protections above, this
means tip-of-release = "full CI + Tier 2 passed on this exact SHA."
- **PyPI trusted publishing** — the `publish` job uploads via OIDC, no
API tokens in GitHub secrets.

## Release procedure

1. Open a PR from `main` to `release`. Wait for CI (including Tier 2) to
go green. Merge.
2. Tag the tip of `release`:
```bash
git checkout release
git pull --ff-only
git tag -a vX.Y.Z -m "vX.Y.Z"
git push origin vX.Y.Z
```
3. The tag push triggers `release.yml`. The `gate` job verifies
`vX.Y.Z` points to the current tip of `release`. If it doesn't, the
whole workflow fails — fix and re-tag.
4. Wheel jobs build for Linux (x86_64, aarch64), macOS (x86_64, aarch64),
Windows (x64) across CPython 3.11/3.12/3.13. The `sdist` job builds
the source distribution.
5. The `publish` job uploads everything to PyPI via OIDC. `skip-existing`
makes re-runs idempotent.

## Common issues

**Gate fails: "tag is not the tip of release."** Someone else merged to
`release` between when you fetched and when you pushed the tag, or you
tagged the wrong branch. Delete the tag locally and on origin, pull
`release`, retag.

```bash
git tag -d vX.Y.Z
git push origin :refs/tags/vX.Y.Z
git checkout release && git pull --ff-only
git tag -a vX.Y.Z -m "vX.Y.Z" && git push origin vX.Y.Z
```

**Publish fails on PyPI.** Most often the version in `pyproject.toml`
hasn't been bumped (PyPI rejects re-uploads of an existing version).
Bump the version on `main`, merge to `release`, retag.
Loading