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
2 changes: 1 addition & 1 deletion .github/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ changelog:
- title: Research & validation
labels: [research, experiment]
- title: Docs, CI & tooling
labels: [docs, ci, tooling]
labels: [docs, documentation, ci, tooling]
# Catch-all LAST so a labelled PR never lands here by accident.
- title: Other changes
labels: ["*"]
59 changes: 59 additions & 0 deletions .github/workflows/migrate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Migrate database

# MANUAL ONLY. Schema migration for an EXISTING database -- never runs on push or merge.
#
# Migration is deliberately separate from seeding: `keel init` seeds the strategy (rules) library
# on a FRESH deployment, while `keel migrate` only evolves an existing database's schema. Seeding
# on migrate would resurrect rules that were deliberately deleted or refuted.
#
# DEFERRED SEAM: today `keel.db` is local, git-ignored and single-user, so CI has no database to
# reach. Once the app is server-hosted, that deployment's database becomes the `db_path` target
# (via a self-hosted runner or a mounted volume) and the release workflow can call this job. Until
# then, dispatching this with no target runs a migration-integrity check instead of pretending to
# migrate something that is not there.
on:
workflow_dispatch:
inputs:
db_path:
description: "Database to migrate. Leave empty to run the migration smoke test instead."
required: false
type: string
default: ""

permissions:
contents: read

jobs:
migrate:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.12

- name: Sync dependencies
run: uv sync --all-extras --dev

- name: Migrate, or smoke-test the migration chain
run: |
set -euo pipefail
DB="${{ inputs.db_path }}"
if [ -n "$DB" ]; then
if [ ! -f "$DB" ]; then
echo "::error::no database at '$DB' -- refusing to create one here."
echo "::error::A fresh deployment is bootstrapped with 'keel init', not this workflow."
exit 1
fi
echo "migrating $DB"
uv run keel migrate --db "$DB"
else
echo "no db_path given -- verifying the migration chain instead"
uv run python scripts/migration_smoke.py
fi
56 changes: 48 additions & 8 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,15 @@ jobs:
printf '%s' "$OUT" | grep -q "DIRTY" && {
echo "::error::artifact reports a dirty tree"; exit 1; } || true

# The release ships a ready-for-live config as a downloadable asset. It must be in
# `confirm` mode: a config that trades unattended straight off a download is exactly what
# this project refuses to ship. Fail the release loudly rather than publish an armed config.
- name: Verify and stage the live config asset
run: |
set -euo pipefail
uv run python -c "from keel.config import load_config; m = load_config('keel/templates/config.live.yaml').auto_trade.mode; assert m == 'confirm', f'live config must be confirm mode, got {m!r}'; print('live config OK: mode=confirm')"
cp keel/templates/config.live.yaml config.yaml

- name: Tag
run: |
set -euo pipefail
Expand All @@ -111,17 +120,35 @@ jobs:
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.
# The change list INLINES each merged PR's body -- a reader should never have to click
# through to a PR to learn what shipped. Grouping still comes from .github/release.yml.
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)"
RANGE="$PREV..HEAD"
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)"
# First release: no previous tag, so walk from the start of history.
RANGE="HEAD"
fi

# Every PR whose commits land in this range, de-duplicated.
: > /tmp/pr-numbers.txt
for sha in $(git log --format=%H "$RANGE"); do
gh api "repos/${{ github.repository }}/commits/$sha/pulls" \
-q '.[].number' 2>/dev/null >> /tmp/pr-numbers.txt || true
done
sort -u -n /tmp/pr-numbers.txt -o /tmp/pr-numbers.txt
echo "found $(wc -l < /tmp/pr-numbers.txt) PRs in $RANGE"

# Fetch each PR's title/body/labels, then compose. jq -s folds the stream into an array.
: > /tmp/prs.ndjson
while read -r n; do
[ -n "$n" ] || continue
gh api "repos/${{ github.repository }}/pulls/$n" \
-q '{number:.number,title:.title,body:(.body // ""),labels:[.labels[].name]}' \
>> /tmp/prs.ndjson
done < /tmp/pr-numbers.txt
jq -s '.' /tmp/prs.ndjson > /tmp/prs.json
GENERATED="$(uv run python scripts/release_notes.py < /tmp/prs.json)"
{
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]\`."
Expand All @@ -139,6 +166,18 @@ jobs:
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 "## Configure"
echo
echo "\`config.yaml\` is attached to this release: the production config, in"
echo "\`auto_trade.mode: confirm\` — keel previews every order and waits for your"
echo "approval. Drop it beside the install (or run \`keel init-config --live\`), put"
echo "your CDP key in a git-ignored \`.env\`, then:"
echo
echo ' keel migrate # existing database: apply schema migrations'
echo ' keel init # fresh deployment: write config + seed candidate rules'
echo
echo "Seeded rules start as \`candidate\` and trade nothing until you promote them."
echo
echo "$GENERATED"
} > /tmp/release-notes.md
echo "composed $(wc -l < /tmp/release-notes.md) lines of notes"
Expand All @@ -147,7 +186,8 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "v${{ inputs.version }}" dist/* \
# dist/* = every workspace wheel; config.yaml = the confirm-mode production config.
gh release create "v${{ inputs.version }}" dist/* config.yaml \
--title "keel v${{ inputs.version }}" \
--notes-file /tmp/release-notes.md
echo "published v${{ inputs.version }}"
Expand Down
57 changes: 50 additions & 7 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,58 @@ against live funds**; `keel --version` warns loudly when so.
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]` → verifies the live config asset is `mode: confirm` → tags `v<version>` → composes
release notes → publishes the GitHub Release with all wheels **and `config.yaml`** attached.

## Release assets

| asset | what it is |
|---|---|
| `keel_trader-<version>-py3-none-any.whl` | the CLI. Install **by path**, never by bare name. |
| `keel_core-*`, `keel_broker_*` wheels | workspace members `keel` depends on; download them all. |
| `config.yaml` | the **production** config: real allowlist/caps in `auto_trade.mode: confirm`. |

`config.yaml` is `keel/templates/config.live.yaml`, committed and reviewed like any other code.
It ships in **confirm** mode — keel previews every order and waits for your approval — so a fresh
download is ready for live use but can never trade unattended. The release **fails loudly** if
that file is ever anything other than `mode: confirm`.

Both templates also ship inside the wheel: `keel init-config` writes the dev one (`mode: paper`,
places nothing) and `keel init-config --live` writes the exact release asset.

## Bootstrapping a deployment

Seeding and migrating are deliberately **separate** operations:

```
keel init # FRESH deployment: write config.yaml + seed the strategy (rules) library
keel migrate # EXISTING database: apply outstanding schema migrations. Never seeds.
```

- **`keel init`** = `init-config` + `rules seed`. Rules are seeded as `candidate`, so nothing
trades until you deliberately `keel rules promote` them.
- **`keel migrate`** is idempotent and schema-only — safe to re-run, and safe against a live
database. It never re-seeds, because that would resurrect rules deliberately deleted or refuted.
`--db <path>` targets a database directly.

The **Migrate database** workflow (Actions → Migrate database → Run workflow) is manual-only. Give
it a `db_path` to migrate that database; leave it empty and it verifies the migration chain
instead (a fresh DB and a downgraded DB both reach `SCHEMA_VERSION`). CI has no database to reach
while `keel.db` is local and git-ignored — once the app is server-hosted, that deployment's
database becomes the `db_path` target and the release can call this job.

## Release notes come from PRs

The change list in each release is **auto-generated from the PRs merged since the previous tag**
(`.github/release.yml`). The unit is the **pull request** — a clear PR title is all that is needed
for a useful entry. Issues and issue↔commit linking are **not** required and are not enforced:
good PRs are the source.
The change list in each release is **auto-generated from the PRs merged since the previous tag**.
The unit is the **pull request**. Issues and issue↔commit linking are **not** required and are not
enforced: good PRs are the source.

Each entry **inlines the PR's description**, not a link to it — a reader should never have to
click through to learn what shipped. So the PR body *is* the release note: write it for someone
reading the release page. `scripts/release_notes.py` composes them (unit-tested in
`tests/test_release_notes.py`), stripping the Claude Code footer, HTML comments and
`Co-Authored-By:` trailers. A PR with an empty body renders as `_(no description)_` — visible,
so it gets fixed.

Labels are **optional** and only affect grouping. Without them the notes are a flat "What's
Changed" list of PR titles, which is fine. With them, PRs are grouped into sections:
Expand All @@ -47,7 +90,7 @@ Changed" list of PR titles, which is fine. With them, PRs are grouped into secti
| `bug`, `fix` | Fixes |
| `compliance`, `rails` | Compliance & rails |
| `research`, `experiment` | Research & validation |
| `docs`, `ci`, `tooling` | Docs, CI & tooling |
| `docs`, `documentation`, `ci`, `tooling` | Docs, CI & tooling |
| `breaking` | ⚠️ Breaking changes |
| `norelease` | *excluded from notes* |

Expand Down
116 changes: 116 additions & 0 deletions docs/superpowers/plans/2026-07-21-release-packaging-bootstrap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Release packaging & bootstrap — Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans or subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax.

**Goal:** Ship workstream B — self-contained PR-body release notes, a confirm-mode production `config.yaml` release asset, and a seed/migrate bootstrap lifecycle with a manually dispatchable migration workflow.

**Architecture:** A tested pure module (`scripts/release_notes.py`) composes notes from PR JSON; `release.yml` fetches PRs and pipes to it, and attaches a committed `config.live.yaml`. A new `keel migrate` CLI wraps the existing idempotent `db.migrate`; `init-config --live` writes the production template; a `workflow_dispatch` `migrate.yml` targets a `db_path` or runs a migration smoke test.

**Tech Stack:** Python 3.12, click, PyYAML (already a dep via config loading), pytest, GitHub Actions, `gh`/`jq`.

## Global Constraints

- Seeded rules stay `candidate`; production config stays `auto_trade.mode: confirm`. Nothing ships armed.
- `keel migrate` is schema-only and idempotent — it never seeds and never places orders.
- Release tooling (`scripts/*`) is NOT shipped in the wheel.
- Full suite green + `uv run ruff check keel tests packages` clean before each commit.
- `.github/release.yml` category order/labels are the single source of truth for grouping.

---

### Task 1: `scripts/release_notes.py` — PR-body note composition

**Files:**
- Create: `scripts/release_notes.py`, `scripts/__init__.py`
- Test: `tests/test_release_notes.py`

**Interfaces:**
- Produces: `PullRequest(number:int, title:str, body:str, labels:tuple[str,...])`; `Category(title:str, labels:tuple[str,...])`; `clean_pr_body(body:str)->str`; `categorize(prs, categories)->list[tuple[Category,list[PullRequest]]]`; `compose_release_notes(prs, categories)->str`; `load_categories(path)->list[Category]`; `DEFAULT_CATEGORIES`.

- [ ] **Step 1:** Write failing tests covering: `norelease` exclusion; first-match-wins category assignment; `"*"` catch-all; footer/HTML-comment/`Co-Authored-By` stripping; blank-line collapse; empty body → `_(no description)_`; `load_categories` against the real `.github/release.yml`.
- [ ] **Step 2:** Run `uv run pytest tests/test_release_notes.py -q` → FAIL (module missing).
- [ ] **Step 3:** Implement the module (dataclasses, regex cleaners, categorize, compose, `load_categories` via yaml, `__main__` reads a JSON array of PRs on stdin and prints notes using `load_categories(".github/release.yml")`).
- [ ] **Step 4:** Run tests → PASS; `ruff check`.
- [ ] **Step 5:** Commit `feat(release): compose release notes from PR bodies`.

### Task 2: `keel migrate` command

**Files:**
- Modify: `keel/cli.py` (new `migrate` command near `init`)
- Test: `tests/test_cli.py` (append)

**Interfaces:**
- Consumes: `keel.data.db.{connect, migrate, SCHEMA_VERSION}`, `ctx.obj["db_path"]`.
- Produces: CLI `keel migrate [--db PATH]` printing `migrated <path>: schema <from> -> <to>` or `<path>: already at schema <n>, nothing to do`.

- [ ] **Step 1:** Write failing tests: fresh DB → `0 -> SCHEMA_VERSION`; second run → `already at`; downgraded (`UPDATE schema_version SET version=1`) → `1 -> SCHEMA_VERSION`.
- [ ] **Step 2:** Run → FAIL.
- [ ] **Step 3:** Add `migrate_cmd` with a `_current_schema_version(conn)` helper (0 when the table is absent), `--db` defaulting to `ctx.obj["db_path"]`.
- [ ] **Step 4:** Run → PASS; ruff.
- [ ] **Step 5:** Commit `feat(cli): keel migrate -- idempotent schema-only migration`.

### Task 3: `config.live.yaml` + `init-config --live`

**Files:**
- Create: `keel/templates/config.live.yaml`
- Modify: `keel/cli.py` (`_template_config_text(live=False)`, `init-config --live`)
- Test: `tests/test_init_and_seed.py` (append)

**Interfaces:**
- Produces: CLI `keel init-config --live` writes the production template; parses via `load_config` as `mode == "confirm"`.

- [ ] **Step 1:** Write failing tests: `--live` writes a file that `load_config` reads as `auto_trade.mode == "confirm"`; default (no flag) stays `paper`; the shipped `config.live.yaml` parses and is `confirm`.
- [ ] **Step 2:** Run → FAIL.
- [ ] **Step 3:** Create `config.live.yaml` (dev template with a production header + `mode: confirm`); add `live` param to `_template_config_text` and a `--live` flag to `init-config`.
- [ ] **Step 4:** Run → PASS; ruff.
- [ ] **Step 5:** Commit `feat(cli): ship a confirm-mode production config template (--live)`.

### Task 4: `scripts/migration_smoke.py`

**Files:**
- Create: `scripts/migration_smoke.py`
- Test: `tests/test_release_notes.py` or a new `tests/test_migration_smoke.py`

**Interfaces:**
- Produces: `main()` that asserts a fresh and a downgraded DB both reach `SCHEMA_VERSION`; exits 0 on success.

- [ ] **Step 1:** Write a failing test importing `scripts.migration_smoke.main` and asserting it runs without raising.
- [ ] **Step 2:** Run → FAIL.
- [ ] **Step 3:** Implement `main()` (tempfile fresh DB → migrate → assert; downgrade to 1 → migrate → assert; cleanup).
- [ ] **Step 4:** Run → PASS; ruff.
- [ ] **Step 5:** Commit `feat(release): migration smoke test for the migrate workflow`.

### Task 5: `release.yml` — PR-body notes + config asset + live tripwire

**Files:**
- Modify: `.github/workflows/release.yml`

- [ ] **Step 1:** Replace the "Compose release notes" step: derive `PREV`, collect unique PR numbers across `git log $RANGE` via `gh api commits/<sha>/pulls`, fetch each PR's `{number,title,body,labels}`, `jq -s` into an array, pipe to `python scripts/release_notes.py`; keep the fixed preamble.
- [ ] **Step 2:** Add a "Verify the live config asset" step: `uv run python` asserts `load_config("keel/templates/config.live.yaml").auto_trade.mode == "confirm"`, then `cp keel/templates/config.live.yaml config.yaml`.
- [ ] **Step 3:** Extend the publish step to attach `config.yaml`: `gh release create "v$V" dist/* config.yaml …`.
- [ ] **Step 4:** `actionlint` if available / manual YAML sanity (`python -c "import yaml,pathlib; yaml.safe_load(pathlib.Path('.github/workflows/release.yml').read_text())"`).
- [ ] **Step 5:** Commit `feat(release): inline PR bodies + attach the confirm-mode config asset`.

### Task 6: `migrate.yml` workflow + `docs/RELEASING.md`

**Files:**
- Create: `.github/workflows/migrate.yml`
- Modify: `docs/RELEASING.md`

- [ ] **Step 1:** Create `migrate.yml`: `workflow_dispatch` with optional `db_path`; sync deps; if `db_path` set → `uv run keel migrate --db "$DB"`, else `uv run python scripts/migration_smoke.py`. Comment documents the deferred release-CI seam.
- [ ] **Step 2:** YAML sanity check both workflows.
- [ ] **Step 3:** Update `docs/RELEASING.md`: config-asset section, fresh-deploy seed path (`keel init`), `keel migrate` + the workflow, and PR-body notes note.
- [ ] **Step 4:** Commit `docs(release): config asset, seed/migrate lifecycle, PR-body notes`.

### Task 7: Integration verification

- [ ] **Step 1:** `uv run pytest -q` (full suite green, count up).
- [ ] **Step 2:** `uv run ruff check keel tests packages scripts` clean.
- [ ] **Step 3:** Dry-run notes locally: pipe a small hand-written PR JSON array to `scripts/release_notes.py` and eyeball the grouped, body-inlined output.
- [ ] **Step 4:** `uv run keel migrate` on a temp DB; `uv run keel init-config --live --config /tmp/live.yaml` then `load_config` it.

## Self-Review

- **Spec coverage:** item 4 → Tasks 1, 5; item 5 → Tasks 3, 5; item 6 → Tasks 2, 4, 6. All spec sections mapped.
- **Placeholders:** none — each task names exact files, functions, commands.
- **Type consistency:** `compose_release_notes(prs, categories)`, `clean_pr_body`, `_current_schema_version`, `_template_config_text(live=...)` used consistently across tasks.
Loading
Loading