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
42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
name: CI

# The release workflow refuses to publish unless tests pass. That guarantee is only worth
# something if tests also run on the way IN -- otherwise `main` can drift red between releases
# and the gate discovers it at the worst moment.
on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
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: Lint
run: uv run ruff check keel tests packages

- name: Test
run: uv run pytest -q

# A build that cannot identify itself must not reach a release. This also catches an
# import-time break in the CLI, which `pytest` alone would not surface as sharply.
- name: Build identity
run: uv run keel --version
133 changes: 133 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
name: Release

# MANUAL ONLY. Nothing about a money-moving tool should ship on a merge.
#
# The workflow deliberately does NOT bump the version itself: a version bump is a human decision
# and belongs in a reviewed PR. This job asserts that `pyproject.toml` already carries the version
# being released and fails loudly otherwise, so CI never writes to `main`.
on:
workflow_dispatch:
inputs:
version:
description: "Semver to release, without a leading v (e.g. 0.2.0). Must already match pyproject.toml."
required: true
type: string

permissions:
contents: write # create the tag and the release

jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # tags, and a real commit history for the build stamp

- name: Validate the version input
run: |
set -euo pipefail
VERSION="${{ inputs.version }}"
if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::'$VERSION' is not a bare semver (expected N.N.N, no leading v)"; exit 1
fi
PYPROJECT="$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)"
if [ "$PYPROJECT" != "$VERSION" ]; then
echo "::error::pyproject.toml says '$PYPROJECT' but you asked to release '$VERSION'."
echo "::error::Bump the version in a reviewed PR first -- this workflow will not write to main."
exit 1
fi
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "::error::tag v$VERSION already exists -- releases are immutable"; exit 1
fi

- 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

# The release gate. A red suite must never produce an artifact that could touch funds.
- name: Lint
run: uv run ruff check keel tests packages

- name: Test
run: uv run pytest -q

# Stamp the build so an INSTALLED artifact can identify itself with no git and no repo
# present. `keel/version.py` prefers this over shelling out to git.
- name: Stamp build info
run: |
set -euo pipefail
COMMIT="$(git rev-parse --short=12 HEAD)"
cat > keel/_build_info.py <<EOF
"""Generated by .github/workflows/release.yml. Do not edit or commit."""

VERSION = "${{ inputs.version }}"
COMMIT = "$COMMIT"
DIRTY = False
EOF
echo "stamped $COMMIT"

# --all-packages: `keel` depends on the workspace members (keel-core, keel-broker-*),
# which are NOT published anywhere. A lone `keel` wheel is uninstallable.
- name: Build
run: uv build --all-packages

- name: Verify the artifact identifies itself
run: |
set -euo pipefail
python -m venv /tmp/verify
# Install by explicit PATH, never by name. The name `keel` belongs to an unrelated
# project on PyPI, so a name-based install can silently fetch a stranger's package --
# unacceptable for a tool that places orders. --find-links resolves the workspace
# members from the same dist/ directory.
/tmp/verify/bin/pip install --quiet --find-links dist dist/keel_trader-${{ inputs.version }}-py3-none-any.whl
OUT="$(/tmp/verify/bin/keel --version)"
echo "$OUT"
printf '%s' "$OUT" | grep -q "${{ inputs.version }}" || {
echo "::error::artifact does not report the released version"; exit 1; }
printf '%s' "$OUT" | grep -q "release" || {
echo "::error::artifact is not stamped as a release build"; exit 1; }
printf '%s' "$OUT" | grep -q "DIRTY" && {
echo "::error::artifact reports a dirty tree"; exit 1; } || true

- name: Tag
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git tag -a "v${{ inputs.version }}" -m "keel v${{ inputs.version }}"
git push origin "v${{ inputs.version }}"

- 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

\`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."
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ dist/

# personal Coinbase exports (PII) -- local only, never committed
transactions/

# Generated by the release workflow at build time; never committed.
keel/_build_info.py
29 changes: 29 additions & 0 deletions keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
from keel.strategy import backtest as backtest_mod
from keel.strategy import promotion as promotion_mod
from keel.types import Candle, Granularity
from keel.version import build_info

DISCLAIMER = (
"keel is a personal tool, not financial advice and not religious (Shariah) advice. "
Expand Down Expand Up @@ -207,7 +208,35 @@ def _build_broker(config: Config) -> Any: # pragma: no cover -- exercised only
# -- root group ---------------------------------------------------------------------------------


def _print_version(ctx: click.Context, param: object, value: bool) -> None:
"""Eager `--version`: print the build identity and exit before any command runs.

Prints the working-tree state too. For a tool that can place orders, "0.1.0 (abc123, DIRTY)"
and "0.1.0 (abc123)" are materially different claims -- the first corresponds to no commit
and cannot be reproduced.
"""
if not value or ctx.resilient_parsing:
return
info = build_info()
click.echo(info.describe())
if not info.is_reproducible:
click.echo(
"warning: this build is NOT reproducible -- it does not correspond to a commit. "
"Do not run it against live funds.",
err=True,
)
ctx.exit()


@click.group()
@click.option(
"--version",
is_flag=True,
callback=_print_version,
expose_value=False,
is_eager=True,
help="Show the running version, commit and working-tree state, then exit.",
)
@click.option(
"--db", "db_path", default=DEFAULT_DB_PATH, show_default=True, help="SQLite DB path."
)
Expand Down
131 changes: 131 additions & 0 deletions keel/version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
"""Report exactly which code is running (version + commit + working-tree state).

For a tool that can move money, *"which build was that?"* has to have an answer. It currently
does not: `version` in `pyproject.toml` has never moved off `0.1.0`, there are no tags, and
`uv run keel` executes whatever happens to be checked out -- including a half-finished edit.

Two sources, in priority order:

1. **`keel/_build_info.py`**, written by the release workflow immediately before `uv build`. An
installed release therefore reports the exact commit it was built from, with no git and no
repository present at runtime.
2. **git**, when running from a checkout. Reports the working commit AND whether the tree is
**dirty** -- the distinction that matters most here, because a dirty tree means the running
code corresponds to no commit at all and the run is not reproducible.

Falls back to `unknown` rather than raising: failing to identify the build is a reason to warn
loudly, not a reason to prevent the tool from starting.
"""

from __future__ import annotations

import subprocess
from dataclasses import dataclass
from importlib import metadata

_GIT_TIMEOUT_SEC = 3


@dataclass(frozen=True)
class BuildInfo:
version: str
commit: str
dirty: bool
source: str # "release" | "checkout" | "unknown"

@property
def is_reproducible(self) -> bool:
"""False when the running code corresponds to no commit -- a dirty tree, or no idea.

A `release` build is only reproducible if it is also clean: a stale stamp in a modified
checkout is exactly the case that must not pass.
"""
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.append(f"[{self.source}]")
return " ".join(parts)


#: The DISTRIBUTION name. Deliberately not "keel": that name is already taken on PyPI by an
#: unrelated project ("Kill proccesses effectively and easily"), so `pip install keel` fetches a
#: stranger's package. For a tool that places live orders, an install path that can resolve to
#: someone else's code is a supply-chain hazard, not a cosmetic clash. The IMPORT package and the
#: CLI command both remain `keel`; only the distribution is renamed.
DISTRIBUTION = "keel-trader"


def _package_version() -> str:
for name in (DISTRIBUTION, "keel"):
try:
return metadata.version(name)
except metadata.PackageNotFoundError:
continue
return "unknown"


def _git(*args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT_SEC,
check=False,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
return result.stdout.strip()


def _embedded():
"""The release stamp, or `None`. A seam so tests can exercise the git path deterministically
regardless of whether a stamp happens to exist on the machine."""
try:
from keel import _build_info as embedded # type: ignore[attr-defined]
except ImportError:
return None
return embedded


def build_info() -> BuildInfo:
"""Resolve the running build. Never raises."""
embedded = _embedded()

if embedded is not None:
stamped_commit = getattr(embedded, "COMMIT", "unknown")
dirty = bool(getattr(embedded, "DIRTY", False))
# ⚠️ A STALE stamp in a working checkout would otherwise claim `[release]` and hide a
# dirty tree -- which is precisely the misreport this module exists to prevent. If git
# is present and disagrees with the stamp, believe git.
head = _git("rev-parse", "--short=12", "HEAD")
if head is not None:
if head != stamped_commit or _git("status", "--porcelain"):
dirty = True
return BuildInfo(
version=getattr(embedded, "VERSION", _package_version()),
commit=stamped_commit,
dirty=dirty,
source="release",
)

commit = _git("rev-parse", "--short=12", "HEAD")
if commit is None:
return BuildInfo(
version=_package_version(), commit="unknown", dirty=False, source="unknown"
)

status = _git("status", "--porcelain")
return BuildInfo(
version=_package_version(),
commit=commit,
# `status` is None only if the second git call failed after the first succeeded --
# treat that as dirty, because "we could not tell" must not read as "clean".
dirty=status is None or bool(status),
source="checkout",
)
6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[project]
name = "keel"
name = "keel-trader"
version = "0.1.0"
description = "Offline-first, broker-agnostic, rule-based spot-trading agent (halal policy by default)"
readme = "README.md"
Expand Down Expand Up @@ -30,6 +30,10 @@ build-backend = "uv_build"

[tool.uv.build-backend]
module-root = ""
# The DISTRIBUTION is `keel-trader` (the name `keel` is taken on PyPI by an unrelated project),
# but the import package and the CLI command stay `keel`. Without this the backend would infer
# `keel_trader/` from the distribution name.
module-name = "keel"

[tool.uv.workspace]
members = ["packages/*"]
Expand Down
Loading
Loading