diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 941998d..96bbd54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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'." + exit 1 + fi + ci: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b850f6e --- /dev/null +++ b/.github/workflows/release.yml @@ -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 + 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 diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..3e49cb2 --- /dev/null +++ b/RELEASING.md @@ -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. + +## 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.