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
24 changes: 24 additions & 0 deletions .github/release.yml
Original file line number Diff line number Diff line change
@@ -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: ["*"]
57 changes: 39 additions & 18 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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."
64 changes: 64 additions & 0 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
@@ -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<version>` → 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.
18 changes: 15 additions & 3 deletions keel/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+<commit>`.

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.
Expand All @@ -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)

Expand Down
21 changes: 21 additions & 0 deletions tests/test_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading