diff --git a/.github/release.yml b/.github/release.yml new file mode 100644 index 00000000..0878d1a1 --- /dev/null +++ b/.github/release.yml @@ -0,0 +1,24 @@ +# Shapes the auto-generated release notes (the GitHub "generate release notes" API, which the +# release workflow calls). Merged PRs since the previous tag are grouped by their LABELS. This is +# why the process going forward is "label every PR": an unlabelled PR falls into "Other" and the +# notes get less useful, not wrong. +changelog: + exclude: + labels: + - norelease + categories: + - title: ⚠️ Breaking changes + labels: [breaking] + - title: Features + labels: [feature, enhancement] + - title: Fixes + labels: [bug, fix] + - title: Compliance & rails + labels: [compliance, rails] + - title: Research & validation + labels: [research, experiment] + - title: Docs, CI & tooling + labels: [docs, ci, tooling] + # Catch-all LAST so a labelled PR never lands here by accident. + - title: Other changes + labels: ["*"] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa061f4f..e3d3945b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -106,28 +106,49 @@ jobs: git tag -a "v${{ inputs.version }}" -m "keel v${{ inputs.version }}" git push origin "v${{ inputs.version }}" + - name: Compose release notes + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + # Auto-generate the change list from merged PRs since the previous tag, categorised by + # .github/release.yml. The tag already exists (previous step), so the API can range on it. + PREV="$(git describe --tags --abbrev=0 "v${{ inputs.version }}^" 2>/dev/null || true)" + if [ -n "$PREV" ]; then + GENERATED="$(gh api "repos/${{ github.repository }}/releases/generate-notes" \ + -f tag_name="v${{ inputs.version }}" -f previous_tag_name="$PREV" -q .body)" + else + # First release: no previous tag, generate from the start of history. + GENERATED="$(gh api "repos/${{ github.repository }}/releases/generate-notes" \ + -f tag_name="v${{ inputs.version }}" -q .body)" + fi + { + echo "Built from $(git rev-parse --short=12 HEAD). Version binds to this hash:" + echo "\`keel --version\` reports \`keel ${{ inputs.version }}+$(git rev-parse --short=12 HEAD) [release]\`." + echo + echo "## Install" + echo + echo "Download **all** wheels from this release into one directory, then install the" + echo "\`keel_trader\` wheel **by path**:" + echo + echo ' pip install --find-links . ./keel_trader-${{ inputs.version }}-py3-none-any.whl' + echo " keel --version" + echo + echo "⚠️ **Never install by bare name.** The distribution is \`keel-trader\`; the name" + echo "\`keel\` on PyPI belongs to an unrelated project, so \`pip install keel\` fetches" + echo "someone else's package. A build reporting **DIRTY** or **[checkout]** is not this" + echo "release and must not be run against live funds." + echo + echo "$GENERATED" + } > /tmp/release-notes.md + echo "composed $(wc -l < /tmp/release-notes.md) lines of notes" + - name: Publish the release env: GH_TOKEN: ${{ github.token }} run: | gh release create "v${{ inputs.version }}" dist/* \ --title "keel v${{ inputs.version }}" \ - --notes "Built from $(git rev-parse --short=12 HEAD). Tests and ruff green at build time. - - ## Install - - Download **all** wheels from this release into one directory, then install the \`keel_trader\` - wheel **by path**: - - pip install --find-links . ./keel_trader-${{ inputs.version }}-py3-none-any.whl - keel --version - - ⚠️ **Never install by bare name.** The distribution is \`keel-trader\`; the name - \`keel\` on PyPI belongs to an unrelated project, so \`pip install keel\` fetches - someone else's package. The import package and the CLI command are both still \`keel\`. - - ## Verifying what you are running + --notes-file /tmp/release-notes.md + echo "published v${{ inputs.version }}" - \`keel --version\` on this artifact reports \`[release]\` with the commit above. A build - reporting **DIRTY** or **[checkout]** is not this release and must not be run against - live funds." diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..16603f62 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,64 @@ +# Releasing keel + +The release path is deliberately manual and human-gated — nothing that can move money ships on a +merge. See `.github/workflows/release.yml`. + +## Build identity + +Every build reports what it is: + +``` +$ keel --version +keel 0.1.0+c11baba726af [release] +``` + +`0.1.0+c11baba726af` is the **canonical build identity**: the semver version bound to the exact +commit hash (`+` is the semver / PEP 440 build-metadata separator). The version alone is ambiguous +— many commits share a version between bumps — so the hash is what actually pins "which code is +this". A build reporting `(DIRTY)` or `[checkout]` corresponds to no commit and **must not be run +against live funds**; `keel --version` warns loudly when so. + +## Cutting a release + +1. **Bump the version in a reviewed PR.** Edit `version` in `pyproject.toml`. The release workflow + refuses to change the version itself — that decision belongs in a PR a human reviewed, so CI + never writes to `main`. (The **first** release needs no bump: `pyproject.toml` already says + `0.1.0`.) +2. **Merge it**, then **Actions → Release → Run workflow**, entering the same version. +3. The workflow: validates the input is semver and matches `pyproject.toml` and no such tag exists + → runs tests + ruff → stamps the commit into `keel/_build_info.py` → `uv build --all-packages` + → installs the wheel into a clean venv **by path** and asserts it self-identifies as a clean + `[release]` → tags `v` → composes release notes → publishes the GitHub Release with all + wheels attached. + +## Release notes come from PRs — so label every PR + +The change list in each release is **auto-generated from the PRs merged since the previous tag**, +grouped by label (`.github/release.yml`). This only produces useful notes if PRs are labelled: + +| label | section | +|---|---| +| `feature`, `enhancement` | Features | +| `bug`, `fix` | Fixes | +| `compliance`, `rails` | Compliance & rails | +| `research`, `experiment` | Research & validation | +| `docs`, `ci`, `tooling` | Docs, CI & tooling | +| `breaking` | ⚠️ Breaking changes | +| `norelease` | *excluded from notes* | + +An unlabelled PR lands in "Other changes" — not wrong, just less useful. **Label PRs before merge.** + +## Issues, and linking commits to them (adopt from the next cycle) + +Until now this project worked PR-per-change with no issue tracker. Going forward, work should be +tracked as **issues**, and PRs/commits should **reference the issue they close**: + +- Open an issue for each unit of planned work (`gh issue create`). +- In the PR body or a commit, write `Closes #N` (or `Refs #N` for partial progress). GitHub then + links the commit and closes the issue on merge, and the reference shows up in the release notes. +- This gives three things the current flow lacks: a backlog that outlives a chat session, a + durable "why" behind each change, and richer auto-generated release notes. + +⚠️ This is a process commitment, not code. It only works if it is actually followed from the next +change onward — the mechanics (labels, `release.yml`, `Closes #N` linking) are in place; the +discipline is the part that has to be adopted. diff --git a/keel/version.py b/keel/version.py index 91ab6fc9..1ae9c1ef 100644 --- a/keel/version.py +++ b/keel/version.py @@ -33,6 +33,18 @@ class BuildInfo: dirty: bool source: str # "release" | "checkout" | "unknown" + @property + def full_version(self) -> str: + """Version bound to the build hash as semver build metadata: `0.1.0+`. + + This is the canonical "which build is this" string -- the version alone is ambiguous + (many commits share a version between bumps), the commit alone omits the human-facing + number. `+` is the semver / PEP 440 local-version separator, so tooling recognises it. + """ + if self.commit == "unknown": + return self.version + return f"{self.version}+{self.commit}" + @property def is_reproducible(self) -> bool: """False when the running code corresponds to no commit -- a dirty tree, or no idea. @@ -43,9 +55,9 @@ def is_reproducible(self) -> bool: return self.source in {"release", "checkout"} and not self.dirty def describe(self) -> str: - parts = [f"keel {self.version}"] - if self.commit != "unknown": - parts.append(f"({self.commit}{', DIRTY' if self.dirty else ''})") + parts = [f"keel {self.full_version}"] + if self.dirty: + parts.append("(DIRTY)") parts.append(f"[{self.source}]") return " ".join(parts) diff --git a/tests/test_version.py b/tests/test_version.py index 5f396c95..09c213fc 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -149,3 +149,24 @@ class _Stamp: info = build_info() assert info.dirty is False assert info.is_reproducible is True + + +# -- version <-> build hash binding -------------------------------------------- + + +def test_full_version_binds_version_to_commit_as_build_metadata(): + info = BuildInfo(version="0.1.0", commit="c11baba726af", dirty=False, source="release") + assert info.full_version == "0.1.0+c11baba726af" + assert info.full_version in info.describe() + + +def test_full_version_omits_an_unknown_commit(): + info = BuildInfo(version="0.1.0", commit="unknown", dirty=False, source="unknown") + assert info.full_version == "0.1.0" + + +def test_describe_still_flags_dirty_and_source(): + dirty = BuildInfo(version="0.1.0", commit="abc", dirty=True, source="checkout").describe() + assert "DIRTY" in dirty and "checkout" in dirty + clean = BuildInfo(version="0.1.0", commit="abc", dirty=False, source="release").describe() + assert "DIRTY" not in clean and "release" in clean