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
17 changes: 0 additions & 17 deletions .github/workflows/publish.yml

This file was deleted.

60 changes: 60 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
name: Release

# Tag-driven: pushing a semver tag publishes to PyPI and creates the matching
# GitHub Release. The old `on: release: published` trigger is gone on purpose —
# this workflow creates a published Release itself, which would re-fire that
# trigger and double-publish. The tag is the sole entry point. By convention a
# tag is only ever cut off a green main, so there is no in-workflow CI gate.
on:
push:
tags:
- '[0-9]+.[0-9]+.[0-9]+' # stable: 2.19.2
- '[0-9]+.[0-9]+.[0-9]+[a-z]+[0-9]+' # pre-release: 2.0.0a5, 2.0.0b1, 2.0.0rc2

# Needed for softprops/action-gh-release to create the GitHub Release.
permissions:
contents: write

jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: extractions/setup-just@v4
- uses: astral-sh/setup-uv@v8.2.0

# PyPI is irreversible, so it runs FIRST: if it fails the job stops and no
# GitHub Release is created advertising a version that never reached PyPI.
# `just publish` derives the version from $GITHUB_REF_NAME (the tag name).
- run: just publish
env:
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}

# Description source: planning/releases/<tag>.md if present (used verbatim,
# no auto-changelog appended); otherwise fall back to GitHub's generated
# notes. A tag containing a letter (2.0.0rc1) is a PEP 440 pre-release, so
# it's flagged and GitHub won't mark it "Latest".
- name: Resolve release metadata
id: meta
run: |
set -euo pipefail
notes="planning/releases/${GITHUB_REF_NAME}.md"
if [ -f "$notes" ]; then
echo "body_path=$notes" >> "$GITHUB_OUTPUT"
echo "generate_notes=false" >> "$GITHUB_OUTPUT"
else
echo "generate_notes=true" >> "$GITHUB_OUTPUT"
fi
if [[ "$GITHUB_REF_NAME" =~ [a-z] ]]; then
echo "prerelease=true" >> "$GITHUB_OUTPUT"
else
echo "prerelease=false" >> "$GITHUB_OUTPUT"
fi

- name: Publish GitHub Release
uses: softprops/action-gh-release@v3
with:
body_path: ${{ steps.meta.outputs.body_path }}
generate_release_notes: ${{ steps.meta.outputs.generate_notes }}
prerelease: ${{ steps.meta.outputs.prerelease }}
draft: false
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ Planning follows a portable two-axis convention (shared with
`architecture/<capability>.md` and sets `status: shipped` + `pr:` +
`outcome:` **in the implementing PR** — there is no folder move. The
change listing is generated: run `just index`.
- **Cutting a release (maintainers)** is tag-driven via
[`.github/workflows/release.yml`](.github/workflows/release.yml): write the
notes at `planning/releases/<version>.md` (used verbatim as the GitHub Release
body), then push a bare semver tag off green `main` —
`git tag 2.19.2 && git push origin 2.19.2`. The workflow runs `just publish`
(the tag sets the version via `uv version`; no `pyproject.toml` bump) to PyPI,
then creates the GitHub Release — PyPI first, so a failed publish creates no
Release. Pre-releases use the PEP 440 form (`2.0.0rc1`, not `2.0.0-alpha.5`).
PyPI is irreversible; there is no CI gate (a tag is the commitment point).

## Code Style

Expand Down