chore: set up branching, CI, and PR workflow
CI / Repo hygiene (pull_request) Successful in 24s
CI / API (lint, types, tests) (pull_request) Successful in 55s
CI / Web (lint, typecheck, build) (pull_request) Successful in 25s
CI / Migrations reversible (pull_request) Successful in 3s

Prepares the repo for parallel agent work. No application code.

- CLAUDE.md: conventions, branch naming, and the six non-negotiable
  invariants from the design (immutable raw bytes, no stored odometers,
  SI integers, dual-layer user isolation, secret containment, single
  ingestion path). Also records a model-allocation policy: the
  orchestrator runs Opus 5, workers default to Sonnet, and Opus is
  reserved for review plus the areas where a mistake is silent and
  expensive (ingest, wear SQL, auth/RLS, the Bryton protocol client).
  And the Gitea Actions gotchas, so nobody rediscovers them:
  GITEA_TOKEN cannot push to the container registry, jobs.*.environment
  is ignored, and cron needs a workflow_dispatch pair.
- CONTRIBUTING.md: day-to-day flow, worktrees for parallel branches,
  review expectations.
- .gitea/workflows/ci.yml: repo hygiene (branch naming, secret scan,
  no ride data in git), plus API/web/migration jobs that guard on whether
  the code exists yet, so CI is meaningful now and grows into the real
  thing rather than being rewritten.
- .gitea/PULL_REQUEST_TEMPLATE.md: forces an honest "how this was
  verified" and an invariant checklist.
- scripts/pr.sh, scripts/review.sh: open and inspect PRs via the Gitea API.
- Directory scaffold with placeholder READMEs.

Agents open PRs; humans merge them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 21:12:22 -04:00
co-authored by Claude Opus 5
parent d3f5ed7e7b
commit d5e473959c
11 changed files with 574 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
## What and why
<!-- What changed, and what problem it solves. Link the issue if there is one. -->
## How this was verified
<!-- Be specific and honest. "Ran the tests" is not enough — say which, and what they proved.
If something is untested, say so here rather than leaving the reviewer to find out. -->
- [ ] CI is green
- [ ] Tests added or updated for the behaviour that changed
- [ ] Verified manually (describe how):
## Invariants
<!-- Tick only what applies to this change. See CLAUDE.md. -->
- [ ] Raw ingested bytes remain immutable; derived tables stay rebuildable
- [ ] No stored odometer added; wear still derived from installs
- [ ] Physical quantities stored as SI integers
- [ ] New user-owned tables have `user_id` + RLS policy + repository scope
- [ ] No secret can reach a response model, log line, or error message
- [ ] Migration survives `upgrade -> downgrade -1 -> upgrade`
## Risks and follow-ups
<!-- What might this break? What did you deliberately leave out? -->
+204
View File
@@ -0,0 +1,204 @@
name: CI
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ gitea.ref }}
cancel-in-progress: true
jobs:
meta:
name: Repo hygiene
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Branch name follows convention
if: gitea.event_name == 'pull_request'
run: |
BRANCH="${{ gitea.head_ref }}"
echo "Branch: $BRANCH"
if echo "$BRANCH" | grep -Eq '^(feat|fix|refactor|test|docs|chore|ci)/[a-z0-9._-]+$'; then
echo "OK"
else
echo "::error::Branch '$BRANCH' does not match <type>/<desc>."
echo "Valid types: feat fix refactor test docs chore ci"
exit 1
fi
- name: No secrets committed
run: |
# Deliberately narrow: high-signal patterns only, so this never cries wolf.
PATTERN='BEGIN (RSA|OPENSSH|EC|PGP) PRIVATE KEY|xoxb-[0-9A-Za-z-]{10,}|AKIA[0-9A-Z]{16}'
if git grep -nIE "$PATTERN" -- . ':!.gitea/workflows/*' ; then
echo "::error::Possible secret committed (see matches above)."
exit 1
fi
echo "No secret patterns found."
- name: No ride data committed
run: |
if git ls-files | grep -E '\.(fit|gpx|tcx)$' | grep -v '^apps/api/tests/fixtures/fit/'; then
echo "::error::Ride files belong in the blob store, not git."
echo "Only apps/api/tests/fixtures/fit/ may contain .fit files (golden fixtures)."
exit 1
fi
echo "Clean."
api:
name: API (lint, types, tests)
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: velodrome
POSTGRES_PASSWORD: velodrome
POSTGRES_DB: velodrome_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql+asyncpg://velodrome:velodrome@postgres:5432/velodrome_test
steps:
- uses: actions/checkout@v4
- name: Is there API code yet?
id: guard
run: |
if [ -n "$(find apps/api -name '*.py' -not -path '*/.*' 2>/dev/null | head -1)" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "No Python sources under apps/api yet — skipping."
fi
- uses: actions/setup-python@v5
if: steps.guard.outputs.present == 'true'
with:
python-version: '3.12'
- name: Cache uv
if: steps.guard.outputs.present == 'true'
uses: actions/cache@v4
with:
path: ~/.cache/uv
key: uv-${{ runner.os }}-${{ hashFiles('apps/api/pyproject.toml') }}
restore-keys: uv-${{ runner.os }}-
- name: Install
if: steps.guard.outputs.present == 'true'
working-directory: apps/api
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv sync --all-extras
- name: Lint (ruff)
if: steps.guard.outputs.present == 'true'
working-directory: apps/api
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run ruff check .
uv run ruff format --check .
- name: Types (mypy --strict)
if: steps.guard.outputs.present == 'true'
working-directory: apps/api
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run mypy --strict velodrome
- name: Tests
if: steps.guard.outputs.present == 'true'
working-directory: apps/api
run: |
export PATH="$HOME/.local/bin:$PATH"
uv run pytest -q
web:
name: Web (lint, typecheck, build)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Is there web code yet?
id: guard
run: |
if [ -f apps/web/package.json ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "No apps/web/package.json yet — skipping."
fi
- uses: actions/setup-node@v4
if: steps.guard.outputs.present == 'true'
with:
node-version: '22'
- name: Install, lint, build
if: steps.guard.outputs.present == 'true'
working-directory: apps/web
run: |
corepack enable
pnpm install --frozen-lockfile
pnpm run lint
pnpm run check
pnpm run build
migrations:
name: Migrations reversible
runs-on: ubuntu-latest
services:
postgres:
image: postgis/postgis:16-3.4
env:
POSTGRES_USER: velodrome
POSTGRES_PASSWORD: velodrome
POSTGRES_DB: velodrome_mig
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgresql+asyncpg://velodrome:velodrome@postgres:5432/velodrome_mig
steps:
- uses: actions/checkout@v4
- name: Are there migrations yet?
id: guard
run: |
if [ -d apps/api/alembic/versions ] && [ -n "$(ls -A apps/api/alembic/versions/*.py 2>/dev/null)" ]; then
echo "present=true" >> "$GITHUB_OUTPUT"
else
echo "present=false" >> "$GITHUB_OUTPUT"
echo "No migrations yet — skipping."
fi
- uses: actions/setup-python@v5
if: steps.guard.outputs.present == 'true'
with:
python-version: '3.12'
- name: upgrade -> downgrade -> upgrade, and check for model drift
if: steps.guard.outputs.present == 'true'
working-directory: apps/api
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
uv sync --all-extras
uv run alembic upgrade head
uv run alembic downgrade -1
uv run alembic upgrade head
# Fails if the models have drifted from the migrations.
uv run alembic check
+176
View File
@@ -0,0 +1,176 @@
# CLAUDE.md
Conventions for this repo. Agents and humans both follow these. Read before making changes.
## What this is
A self-hosted cycling app: ride sync from a Bryton Rider 650, mileage tracking, spare-parts
inventory, maintenance records with mileage-milestone reminders. See `docs/PLAN.md` for the full
design, `docs/DECISIONS.md` for settled decisions, `docs/RESEARCH.md` for source material.
**Read `docs/DECISIONS.md` before proposing an architectural change.** If a decision there is wrong,
say so and argue it — but don't silently contradict it.
## Layout
```
apps/api/ Python 3.12 / FastAPI / SQLAlchemy async / Alembic
apps/web/ SvelteKit static SPA (installable PWA)
packages/openapi/ openapi.json — COMMITTED contract artefact, CI enforces it matches the code
deploy/ docker-compose, Caddyfile, systemd units, backup scripts
docs/ plan, decisions, research
scripts/ repo tooling (PR helpers, etc.)
.gitea/workflows/ CI
```
## Non-negotiable invariants
These are load-bearing. Breaking one is a correctness bug, not a style choice.
1. **Raw bytes are the only truth.** Every ingested file is written to the content-addressed blob
store *before* parsing, and is never mutated or deleted. Every table is a rebuildable projection:
deleting everything derived from a `raw_file_id` and re-parsing must be semantically a no-op.
2. **No odometer columns.** Component wear is always derived by replaying activities against
time-ranged `component_installs`. Never add a stored running total to a component.
3. **All physical quantities are SI integers** in storage — metres, seconds, mm/s, centimetres,
grams, minor currency units. Imperial is display-only. Never store a float mile.
4. **Every user-owned table has `user_id`, an RLS policy, and a repository-layer scope.** Both
layers, always. Never rely on the query alone.
5. **Secrets never leave the server.** The Bryton credential is password-equivalent. It must not
appear in any API response model, any log line, or any error message.
6. **One ingestion path.** All sources funnel through `ingest_bytes()`. Never add a second parse
path for a new source.
## Style
- **Python:** `ruff` (lint + format), `mypy --strict`. Type everything. Async throughout; no sync DB
calls in request handlers.
- **SQL:** migrations via Alembic only, never manual DDL. Every migration must survive
`upgrade -> downgrade -1 -> upgrade`.
- **Tests:** pytest against a real Postgres service container, never mocks for DB behaviour.
Parser changes need a golden fixture in `apps/api/tests/fixtures/fit/`.
- **Commits:** imperative mood, explain *why* in the body. Conventional-commit prefixes
(`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, `chore:`, `ci:`).
- Match surrounding code. Don't introduce a new pattern when one exists.
## Branching
**Never commit directly to `main`.** `main` is protected and only moves via merged PRs.
```
feat/<scope>-<short-desc> new capability feat/ingest-fit-parser
fix/<scope>-<short-desc> bug fix fix/wear-wet-multiplier
refactor/<scope>-<desc> no behaviour change
test/<scope>-<desc> tests only
docs/<desc> documentation
chore/<desc> tooling, deps, CI
```
Scope is usually the area: `ingest`, `wear`, `auth`, `notify`, `web`, `deploy`, `ci`.
One branch = one reviewable change. If a branch grows past roughly 400 changed lines, it should
probably have been two.
## PR workflow
1. Branch from an up-to-date `main`.
2. Commit in logical steps. Push the branch.
3. Open a PR with `scripts/pr.sh` (see below) or the web UI. Fill in the template honestly —
especially "How this was verified."
4. CI must be green. A red PR is not ready for review, and shouldn't be requested.
5. Review happens on the PR. Address feedback with new commits, don't force-push over review history
unless asked.
6. Squash-merge into `main`. Delete the branch.
**Agents:** you open PRs, you do not merge them. Merging is a human decision.
## Model allocation
The orchestrator runs **Opus 5**. Worker agents do not default to it.
The rule is **match the model to the cost of being wrong, not the size of the task.** A big,
well-specified job with obvious failure modes is cheap work. A small change to dedupe logic that
silently corrupts data for six months is expensive work.
### Opus 5 — orchestration, and anything subtly wrong-able
- Task decomposition, planning, and **all code review** (see below — this is where it pays for itself)
- Anything touching the six invariants above
- `ingest/` — FIT parsing, the three dedupe layers, activity-vs-course discrimination. Errors here
are silent and corrupt the archive.
- `wear/` — the wear SQL. Wrong numbers that still look plausible are the worst failure mode in the
product, because nobody notices.
- `auth/`, RLS policies — security, and a mistake exposes another user's data.
- `sources/bryton/` — a reverse-engineered protocol with no spec to check against.
- Schema migrations that alter or drop existing columns.
- Debugging anything that two Sonnet attempts have already failed to fix.
### Sonnet — the bulk of implementation
Default for normal feature work. `docs/PLAN.md` is detailed enough that most implementation is
careful transcription plus ordinary judgement, and CI catches the rest:
- CRUD endpoints, Pydantic models, repository methods
- SvelteKit components, routes, styling, the service worker
- Tests against an already-decided behaviour
- Additive migrations, docker-compose and Caddy config, CI workflows
- Documentation
### Haiku — mechanical work
- Dependency bumps, formatting, renames, changelog entries
- Log triage, "find every call site of X"
- Anything where a script would also work
### The economics
**Sonnet implements, Opus reviews** is the default pairing, and it is much cheaper than Opus
implementing while catching most of the same problems. Review reads a focused diff; implementation
reads the whole repo and writes for hours. If budget is tight, cut Opus from implementation before
you cut it from review.
Escalate a task to a stronger model when it has actually failed, not preemptively. Two failed Sonnet
attempts is a real signal; "this feels hard" is not.
Never run more agents in parallel than there are genuinely independent branches of work. Parallel
agents that touch the same files cost more than one agent working in sequence, because the merge
conflicts and re-review are paid twice.
## Working as a parallel agent
Each agent works in its own git worktree so parallel branches don't collide:
```bash
git worktree add ../bike-app-<branch> -b feat/<scope>-<desc>
# work there, push, open PR
git worktree remove ../bike-app-<branch>
```
Rules:
- **Stay in your lane.** Touch only files your task needs. If you need a change in someone else's
area, note it in the PR rather than making it.
- **Never rebase or force-push another agent's branch.**
- **Rebase on `main` before opening the PR**, so the reviewer sees a clean diff.
- **Don't invent scope.** If the task is ambiguous, ask rather than guess — a wrong guess costs a
full review cycle.
- **Report honestly.** If tests fail, say so with the output. If you skipped something, say that.
"Done" means done and verified.
## What needs a human decision
Don't do these autonomously:
- Merging a PR
- Anything touching `docs/DECISIONS.md` (propose it in a PR, argue the case)
- Schema changes that drop or rewrite existing columns
- Anything that sends data off the server, or adds a third-party runtime dependency on one
- Rotating or changing secrets
- Force-pushing anything, ever, to `main`
## Environment
- Gitea at `http://192.168.0.3:3000`, repo `BBergle/bike-app`, act_runner on the same host.
- SSH remote `git@192.168.0.3:BBergle/bike-app.git`, key pinned in `~/.ssh/config`.
- **`secrets.GITEA_TOKEN` cannot push to the Gitea container registry** — use the `REGISTRY_TOKEN`
PAT secret (`package:write`). This is a documented Gitea limitation, not a misconfiguration.
- `jobs.*.environment` is silently ignored by Gitea Actions. Don't build a gate on it.
- Always pair `schedule:` with `workflow_dispatch:` — Gitea's cron has shipped flaky.
+76
View File
@@ -0,0 +1,76 @@
# Contributing
Short version: branch, commit, push, open a PR, get it reviewed, squash-merge.
`main` is never committed to directly.
Conventions live in [`CLAUDE.md`](CLAUDE.md) — branch naming, style, the non-negotiable
invariants, and the rules for agents working in parallel. Read that first.
## One-time setup
SSH to Gitea should already be pinned in `~/.ssh/config`:
```
Host 192.168.0.3
HostName 192.168.0.3
User git
IdentityFile ~/.ssh/gitea
IdentitiesOnly yes
```
`IdentitiesOnly yes` matters: without it, SSH may offer an older deploy key first and Gitea will
authorize against *that* key's narrower permissions.
For opening PRs from the command line, create a Gitea token at
http://192.168.0.3:3000/user/settings/applications with scopes `read:user`, `write:repository`,
`write:issue`, then:
```bash
export GITEA_TOKEN=<token> # add to your shell profile
export GITEA_URL=http://192.168.0.3:3000
```
## Day-to-day
```bash
git switch main && git pull
git switch -c feat/ingest-fit-parser
# work, commit in logical steps
git push -u origin feat/ingest-fit-parser
scripts/pr.sh "feat(ingest): parse Bryton FIT activities"
```
`scripts/pr.sh` opens the PR against `main` and prints its URL. Pass `--draft` if it isn't ready.
## Parallel work
Use worktrees so branches don't fight over one checkout:
```bash
git worktree add ../bike-app-ingest -b feat/ingest-fit-parser
cd ../bike-app-ingest
# ...
git worktree remove ../bike-app-ingest
```
## Review
- CI must be green before review is requested. A red PR isn't ready.
- Reviewers look for: correctness, the invariants in `CLAUDE.md`, whether the tests actually prove
what the PR claims, and whether it reuses what already exists.
- Address feedback with new commits. Don't force-push over review history unless asked.
- **Agents open PRs; humans merge them.**
## Commits
Conventional prefixes, imperative mood, and a body that explains *why*:
```
feat(ingest): discriminate activities from Bryton course files
Bryton writes routes as .fit too, so a naive importer turns saved routes
into phantom rides. Check file_id.type first, falling back to message-shape
inspection since Bryton's encoder omits it on some firmware.
```
+1
View File
@@ -0,0 +1 @@
Python 3.12 / FastAPI / SQLAlchemy async / Alembic. Not yet scaffolded — Phase 0.
+1
View File
@@ -0,0 +1 @@
Golden FIT fixtures for parser tests. Real Rider 650 files go here.
+1
View File
@@ -0,0 +1 @@
SvelteKit static SPA, installable PWA. Not yet scaffolded — Phase 0.
+1
View File
@@ -0,0 +1 @@
docker-compose, Caddyfile, systemd backup units. Not yet written — Phase 0.
+1
View File
@@ -0,0 +1 @@
openapi.json lives here, committed. CI enforces it matches the code.
Executable
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Open a pull request against main from the current branch.
#
# scripts/pr.sh "feat(ingest): parse Bryton FIT activities" [--draft]
#
# Requires GITEA_TOKEN (scopes: write:repository, write:issue) and optionally GITEA_URL.
set -euo pipefail
GITEA_URL="${GITEA_URL:-http://192.168.0.3:3000}"
OWNER="${GITEA_OWNER:-BBergle}"
REPO="${GITEA_REPO:-bike-app}"
BASE="${PR_BASE:-main}"
die() { echo "error: $*" >&2; exit 1; }
[ -n "${GITEA_TOKEN:-}" ] || die "GITEA_TOKEN is not set. See CONTRIBUTING.md."
command -v jq >/dev/null || die "jq is required (brew install jq)."
TITLE="${1:-}"
[ -n "$TITLE" ] || die "usage: scripts/pr.sh \"<title>\" [--draft]"
DRAFT=false
[ "${2:-}" = "--draft" ] && DRAFT=true
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
[ "$BRANCH" != "$BASE" ] || die "refusing to open a PR from $BASE onto itself."
git diff --quiet && git diff --cached --quiet || die "working tree is dirty; commit or stash first."
if ! git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then
echo "Branch not on the remote yet; pushing."
git push -u origin "$BRANCH"
fi
# Prefer the PR template; otherwise summarise the commits on this branch.
if [ -f .gitea/PULL_REQUEST_TEMPLATE.md ]; then
BODY="$(cat .gitea/PULL_REQUEST_TEMPLATE.md)"
else
BODY="$(git log --format='- %s' "origin/$BASE..$BRANCH")"
fi
RESPONSE="$(jq -n \
--arg title "$TITLE" --arg body "$BODY" \
--arg head "$BRANCH" --arg base "$BASE" \
--argjson draft "$DRAFT" \
'{title:$title, body:$body, head:$head, base:$base}' \
| curl -sS -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d @- \
"$GITEA_URL/api/v1/repos/$OWNER/$REPO/pulls")"
if URL="$(echo "$RESPONSE" | jq -er '.html_url' 2>/dev/null)"; then
echo "PR opened: $URL"
else
echo "Gitea rejected the request:" >&2
echo "$RESPONSE" | jq . >&2 2>/dev/null || echo "$RESPONSE" >&2
exit 1
fi
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Fetch a PR's diff and metadata for review.
#
# scripts/review.sh 7 # print the diff
# scripts/review.sh 7 --meta # title, author, branch, CI state
#
# Requires GITEA_TOKEN.
set -euo pipefail
GITEA_URL="${GITEA_URL:-http://192.168.0.3:3000}"
OWNER="${GITEA_OWNER:-BBergle}"
REPO="${GITEA_REPO:-bike-app}"
[ -n "${GITEA_TOKEN:-}" ] || { echo "error: GITEA_TOKEN not set" >&2; exit 1; }
PR="${1:-}"
[ -n "$PR" ] || { echo "usage: scripts/review.sh <pr-number> [--meta]" >&2; exit 1; }
api() { curl -sS -H "Authorization: token $GITEA_TOKEN" "$GITEA_URL/api/v1/repos/$OWNER/$REPO/$1"; }
if [ "${2:-}" = "--meta" ]; then
api "pulls/$PR" | jq '{number, title, user: .user.login, head: .head.ref, base: .base.ref,
mergeable, draft, additions, deletions, changed_files}'
echo "--- commit status ---"
SHA="$(api "pulls/$PR" | jq -r '.head.sha')"
api "commits/$SHA/status" | jq '{state, statuses: [.statuses[]? | {context, state}]}'
else
api "pulls/$PR.diff"
fi