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

# Two-lane CI for the Routstr e2e harness (see docs/ci.md):
#
# pr — fast, no-Docker, no-funds. Runs on every PR + push to main.
# Gates on: ruff + mypy(runner) + the unit/mock pytest suite
# + the orchestrator money-path *pipeline* guard (smoke passes /
# smoke_fail fails -> non-zero exit). Uses only local nutshell
# FakeWallet-free mock primitives; never touches real sats or a
# real mint.
#
# nightly — Docker lane (scheduled + manual). Brings up the compose
# topology and runs the services_required money-path scenarios
# (foreign-mint swap + retry once Routstr/routstr-testing#1 merges)
# against local nutshell FakeWallet mints — real Cashu crypto,
# zero real sats. Stubbed here; enable once PR #1 lands.

on:
pull_request:
push:
branches: [main]
schedule:
# 03:17 UTC nightly — odd minute to dodge the top-of-hour scheduler rush.
- cron: "17 3 * * *"
workflow_dispatch:
inputs:
run_nightly:
description: "Force the Docker nightly lane on a manual run"
type: boolean
default: false

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

jobs:
# ──────────────────────────────────────────────────────────────────────
# Fast PR lane: no Docker, no funds. This is the gate on every PR.
# ──────────────────────────────────────────────────────────────────────
pr:
name: pr (no-docker / mock money-path)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4

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

- name: Sync env (incl. dev tools)
run: uv sync --group dev --frozen

- name: Lint (ruff)
run: uv run ruff check .

- name: Type-check money-path package (mypy runner/)
run: uv run mypy

- name: Unit + mock suite (no Docker, no funds)
# The full pytest suite. Service-backed integration tests guard
# themselves and SKIP without their env (no REMOTE_NODE_URLS /
# X_CASHU_TOKENS / funded daemon), so nothing here needs Docker or
# real sats. SCENARIO_ID mirrors what the orchestrator exports so the
# smoke env-propagation assertion holds; test_smoke_fail.py is the
# deliberately-red negative control and is exercised via the
# orchestrator guard below, not collected directly.
env:
SCENARIO_ID: smoke
run: uv run pytest tests --deselect tests/test_smoke_fail.py -q

- name: Money-path pipeline guard — smoke MUST pass (exit 0)
# Drives the no-services smoke scenario through the orchestrator and
# asserts the *process exit code* is 0. Proves the runner -> pytest ->
# junit -> sqlite pipeline is intact.
env:
SKIP_SYNC: "1"
run: uv run python -m runner.orchestrate --scenario smoke --token placeholder

- name: Money-path pipeline guard — smoke_fail MUST fail (non-zero)
# The anti-silent-pass check: a scenario whose tests fail has to make
# the orchestrator exit non-zero, or the nightly money-path lane would
# be green while swap/refund logic is red. We invert the exit code:
# this step fails the build if the orchestrator wrongly returns 0.
env:
SKIP_SYNC: "1"
run: |
if uv run python -m runner.orchestrate --scenario smoke_fail --token placeholder; then
echo "::error::orchestrator exited 0 for a FAILED scenario — the money-path CI guard is a silent-pass"
exit 1
else
echo "orchestrator correctly returned non-zero for smoke_fail"
fi

# ──────────────────────────────────────────────────────────────────────
# Nightly Docker lane (STUB). Real Cashu crypto via local nutshell mints,
# zero real sats. Enable the scenario steps once Routstr/routstr-testing#1
# (foreign-mint swap + retry) merges to main.
# ──────────────────────────────────────────────────────────────────────
nightly:
name: nightly (docker money-path)
runs-on: ubuntu-latest
timeout-minutes: 45
# Only on the schedule or an explicit manual opt-in — never on PRs.
if: github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && inputs.run_nightly)
steps:
- uses: actions/checkout@v4

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

- name: Sync env
run: uv sync --frozen

- name: Bring up compose topology (vendor refs default to main)
run: make up

# --- Money-path scenarios (enable after Routstr/routstr-testing#1 merges) ---
# PR #1 adds two services_required scenarios that exercise routstr-core's
# swap_to_primary_mint fee handling against local nutshell FakeWallet
# mints + a fault-proxy. Until it lands, these steps are intentionally
# inert so the lane is wired but does not reference files not yet on main.
#
# - name: Foreign-mint swap (covers core#549/#468 fund-loss class)
# env: { SKIP_SYNC: "1", KEEP_UP: "1" }
# run: uv run python -m runner.orchestrate --scenario swap_foreign_mint --token placeholder
#
# - name: Foreign-mint swap retry
# # Pin ROUTSTR_CORE_REF=refs/pull/549/head until core#549 merges;
# # on core main this asserts the no-retry 400/abort path.
# env: { SKIP_SYNC: "1", KEEP_UP: "1", ROUTSTR_CORE_REF: "refs/pull/549/head" }
# run: uv run python -m runner.orchestrate --scenario swap_foreign_mint_retry --token placeholder

- name: Nightly stub notice
run: echo "Docker topology is up. Money-path scenario steps are stubbed until Routstr/routstr-testing#1 merges."

- name: Tear down
if: always()
run: make down
46 changes: 46 additions & 0 deletions docs/ci.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# CI lanes

This repo ships a two-lane GitHub Actions workflow (`.github/workflows/ci.yml`).

## `pr` lane — fast, no Docker, no funds (gates every PR)

Runs on every pull request and push to `main`. Steps:

1. `uv sync --group dev --frozen` — hermetic env incl. pinned `ruff`/`mypy`.
2. `ruff check .` — lint gate (config in `pyproject.toml`).
3. `mypy` — type-checks the **money-path orchestrator package** `runner/`
only (`[tool.mypy] files = ["runner"]`). `server/` uses idiomatic SQLModel
query expressions that trip SQLAlchemy's typing overloads and is out of
scope for this lane.
4. `pytest tests --deselect tests/test_smoke_fail.py` with `SCENARIO_ID=smoke`.
The full unit/mock suite. Service-backed integration tests guard themselves
and **skip** without their env (no `REMOTE_NODE_URLS`, `X_CASHU_TOKENS`, or a
funded daemon), so nothing here needs Docker or real sats. The mock
money-path guard `tests/integration/test_spend_unit.py` (Cashu TokenV4
amount decode + spend accounting) runs here.
5. **Money-path pipeline guards** — drive the orchestrator and assert its
*process exit code*:
- `smoke` MUST exit 0 (pipeline intact).
- `smoke_fail` MUST exit non-zero (anti-silent-pass: a scenario whose tests
fail has to fail the build). The step inverts the exit code so a wrong
`0` fails CI.

No real sats, no mint, no Docker. Safe to run on forked-PR runners.

## `nightly` lane — Docker money-path (scheduled / manual)

Runs on a nightly `cron` (and `workflow_dispatch` with `run_nightly=true`).
Never runs on PRs. Brings up the compose topology (`make up`) — local nutshell
FakeWallet mints, so **real Cashu crypto, zero real sats** — and is wired to run
the `services_required` swap scenarios.

The swap scenarios (`swap_foreign_mint`, `swap_foreign_mint_retry`) are added by
[`Routstr/routstr-testing#1`](https://github.com/Routstr/routstr-testing/pull/1)
(foreign-mint swap + retry; covers `routstr-core#549`/`#468` fund-loss). Their
orchestrator steps are present but **commented out** in the workflow until PR #1
merges to `main`, so the lane never references files not yet on `main`. Once
PR #1 lands: uncomment the two steps. Pin `ROUTSTR_CORE_REF=refs/pull/549/head`
for the *retry* scenario until `core#549` merges (on `core` main it asserts the
no-retry 400/abort path), then drop the pin.

Vendor refs default to `main` (`scripts/sync.sh`) so CI tests shipped code.
22 changes: 22 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,31 @@ dependencies = [
"uvicorn>=0.27",
]

[dependency-groups]
# Tooling the no-docker `pr` CI lane runs (ruff + mypy). Pinned and declared
# here so CI is hermetic (`uv sync --group dev`) instead of leaking whatever
# ruff/mypy happen to be on a contributor's PATH. types-PyYAML supplies the
# stubs mypy needs for runner/{providers,scenario}.py.
dev = [
"ruff==0.1.7",
"mypy==1.9.0",
"types-PyYAML>=6.0",
]

[tool.setuptools]
packages = ["runner", "server", "tests"]

[tool.mypy]
# The no-docker CI lane type-checks the money-path orchestrator package
# (runner/) only. server/ uses idiomatic SQLModel query expressions that
# trip SQLAlchemy's typing overloads — out of scope for this lane.
python_version = "3.11"
files = ["runner"]

[tool.ruff]
target-version = "py311"
line-length = 100

[tool.pytest.ini_options]
markers = [
"requires_funded_daemon: test depends on a topped-up routstrd",
Expand Down
26 changes: 24 additions & 2 deletions runner/orchestrate.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any

from sqlmodel import select

Expand Down Expand Up @@ -830,11 +829,34 @@ def main(argv: list[str] | None = None) -> int:
print(f"[orchestrate] upstream config error: {exc}", file=sys.stderr)
return 2

# The run row carries the authoritative outcome. CI lanes drive this
# entry point and gate on the *process exit code*, so a scenario whose
# tests failed (status=failed) or that errored (status=error, e.g. no
# tests collected / compose failure / timeout) MUST surface as a non-zero
# exit. Otherwise a money-path guard lane silently passes while the
# swap/refund logic under test is red.
status = _read_run_status(Path(args.db), run_id)

# Echo a machine-friendly summary so callers (FastAPI server in ROU-134)
# can pick up the new run id without re-querying SQLite.
print(json.dumps({"run_id": run_id, "db": args.db}))
print(json.dumps({"run_id": run_id, "db": args.db, "status": status}))

if status in {"failed", "error"}:
print(
f"[orchestrate] run #{run_id} status={status} → exit 1",
file=sys.stderr,
)
return 1
return 0


def _read_run_status(db_path: Path, run_id: int) -> str | None:
"""Read the finalized status of a run row (CI exit-code source)."""
engine = get_engine(db_path)
with get_session(engine) as session:
row = session.get(Run, run_id)
return row.status if row is not None else None


if __name__ == "__main__":
raise SystemExit(main())
22 changes: 16 additions & 6 deletions server/runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from pathlib import Path
from typing import Optional

from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi import APIRouter, HTTPException, Query, Request, status
from fastapi.responses import PlainTextResponse
from sqlalchemy import desc
from sqlmodel import select
Expand Down Expand Up @@ -327,11 +327,21 @@ def spawn_orchestrator(
text=True,
timeout=int(env.get("SERVER_ORCHESTRATE_TIMEOUT", "1800")),
)
if proc.returncode != 0:
raise OrchestratorError(
f"orchestrator exited {proc.returncode}: {proc.stderr.strip()[:400]}"
)
return _parse_run_id(proc.stdout)
# The orchestrator now exits non-zero when a scenario's tests fail/error
# (so CLI/CI can gate on the exit code). For the UI that is still a
# *recorded* run — the run row exists with status=failed/error and should
# be returned so the UI can show it. A non-zero exit with NO run_id summary
# line means the orchestrator never recorded a run (e.g. config error) — a
# true failure we surface as an OrchestratorError.
try:
return _parse_run_id(proc.stdout)
except OrchestratorError:
if proc.returncode != 0:
raise OrchestratorError(
f"orchestrator exited {proc.returncode}: "
f"{proc.stderr.strip()[:400]}"
) from None
raise


def _parse_run_id(stdout: str) -> int:
Expand Down
2 changes: 1 addition & 1 deletion tests/cli/test_handwritten.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import pytest

from tests.cli.helpers import run_cli, NODE_A_INTERNAL
from tests.cli.helpers import run_cli

# CLI side-effects here are local config writes (init/instruct/show) — they
# don't mutate the routstr node — so the whole module is safe to point at a
Expand Down
1 change: 0 additions & 1 deletion tests/test_orchestrate_balance.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from pathlib import Path

import pytest
from sqlmodel import select

from runner import orchestrate as orch_mod
from runner.models import Run, get_engine, get_session
Expand Down
Loading
Loading