diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c59709d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,115 @@ +# CLAUDE.md — how work lands in this repo + +Read this before doing anything durable. It is the delivery contract for AI coding +sessions and agents. `DECISIONS-LOG.md`'s last entry is the current PM handoff and +takes precedence on *what* to work on; this file governs *how* it lands. + +## 1. Never push to `main`. Ever. + +Every durable change reaches `main` exactly one way: + + branch -> push the branch -> open a PR -> CI gate green -> human approval -> merge + +There is no other path. Specifically: + +- **Do not `git push origin main`.** +- **Do not `git push origin HEAD:main`.** Same thing wearing a hat. +- **Do not merge your own PR.** A human approves and clicks merge. +- **Do not `kubectl apply` / `edit` / `patch` a durable change.** Flux reconciles `main`; + anything applied by hand is drift that the next reconcile reverts or, worse, adopts. + +> **Many runbooks under `runbooks/` still say `git push origin main` IS the deploy.** +> That wording is **stale** — it predates the PR flow. It describes what Flux does +> (main is what the cluster follows), not what you are permitted to do. Honour this +> file, not that line. Fixing those runbooks is tracked work. + +## 2. Enforcement is currently WEAKER than this rule. Follow the rule anyway. + +Verified against the live Gitea on 2026-09-29: + +| Expected | Actual | +|---|---| +| `main` protected, direct push rejected | **No branch protection.** `protected: false`, `user_can_push: true` | +| PR blocked until the gate passes | **`enable_status_check: false`**, `status_check_contexts: []` | +| Gate runs on every PR | **No Actions runner is registered** (repo, org and site level all report zero) | +| Approval required before merge | **`required_approvals: 0`** | + +So nothing mechanically stops you from pushing to `main` right now. **That is a gap +being closed, not permission.** `DECISIONS-LOG.md` (2026-08-31) claims the gate is a +"required status check" with a runner online; that claim is not true of the live +system, which is how this went unnoticed. + +**Consequence for you:** because CI cannot run, the **local toolbox gate is the only +real gate**. Run it and quote its numbers in your report. Never describe a PR as +"green" on the strength of CI that did not execute. + +## 3. Gate before you push + + ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh' + +Exit codes are a contract — do not flatten them: + +| rc | Meaning | +|---|---| +| 0 | PASS | +| 1 | BLOCKED — something failed | +| 3 | UNDETERMINED — a stage did not run. **Never report this as green.** | + +`spec-immutability` reaches rc 3 whenever the sibling spec repo is not checked out +beside this one; that single skip is expected and tolerated. Any *other* skip is a +real gap in your run. + +**Never gate on the host.** `promtool` and `kustomize` are not on the host `$PATH`, +so checks SKIP and you get a false rc 3. Only the pinned toolbox result counts. + +### Running the gate from a git worktree + +Two unrelated failures, both with known workarounds — put both in any agent brief: + +1. **`dev.sh` refuses to start:** `infra/ansible/.cache/prod.kubeconfig` is untracked + and exists only in the main checkout. Do **not** copy it (it is a credential). + Use `DEV_KUBECONFIG=/dev/null` — the gate contacts no cluster, so L2/L3 scaffold-skip + normally. +2. **Every `git` call inside the container fails:** a worktree's `.git` is a pointer to a + gitdir outside the mounted root. `dev.sh` takes no extra-mount flag, so inject one via + the `ENGINE` hook — a shim that intercepts `run` and re-execs + `podman run -v /path/to/main/.git:/path/to/main/.git:ro "$@"`. A traceback in + `tracked_files()` is this bug, not a broken tree. + +## 4. Branch naming + +Use `feat/`, `fix/`, `docs/`, `chore/`. Note that `.gitea/workflows/gate.yml`'s **push** +trigger lists `feature/**` — not `feat/**` — so branch pushes get no fast-feedback run. +The `pull_request` trigger fires regardless of branch name, so the PR gate is unaffected. + +## 5. Cluster access + +`kubectl` does not work on the operator workstation; the toolchain lives only in the +pinned container. Always name the context explicitly: + + ./infra/devcontainer/dev.sh run bash -c 'kubectl --context k3s-prod get nodes' + +`dev.sh` merges both clusters and picks a current-context **per invocation**; that +default has already changed once. Never rely on it. Do not run two `dev.sh` +invocations concurrently. + +**Mutating `kubectl` and `flux reconcile` are operator actions, not agent actions.** + +## 6. Secrets + +- **Never `sops -d` a file into a terminal, a log, or a transcript.** +- **Never print a Secret's `.data`/`.stringData`** when checking whether it exists — + list key names or object names only. A password has been leaked into a transcript + this way before. +- Encrypted files are `*.sops.yaml` or live under `secrets/`. The root `.sops.yaml` is + the single creation-rules file; do not add a second one in a subtree. +- A short-lived credential (e.g. a plex.tv claim token, ~minutes) cannot survive the PR + flow. Do not design around committing one — read the expiry from the issuing page and + treat a burnt token as free to retry. + +## 7. Before you finish + +Append a handoff entry to `DECISIONS-LOG.md` describing what you changed, what you +verified, and what you did **not** verify. State gaps plainly — an honest +"UNVERIFIED" is worth more than an optimistic "green", and this repo has already been +burned by the difference. diff --git a/DECISIONS-LOG.md b/DECISIONS-LOG.md index d1ce8c9..bee4174 100644 --- a/DECISIONS-LOG.md +++ b/DECISIONS-LOG.md @@ -4716,3 +4716,68 @@ asleep. `git worktree remove` preserves branches and their commits, so only that 6. **Five `public` services have no working public path** — waiting on WAN forwarding, deliberately. 7. Backlog cherry-picks: `feat/egress-gateway-staleness-alert`, two `rescued/*` fixes, two rendered manifests nothing applies (the keycloak one would stand up a conflicting instance). + +## 2026-09-29 — The CI/CD merge gate does not exist: no runner, no branch protection. **PM HANDOFF.** + +Went to land the plex claim wiring through the documented PR flow and discovered the flow has +never run. **PR #1 is the first pull request this repository has ever had.** It is still queued. + +### What is actually true of the live Gitea (verified via API, 2026-09-29) + +| Claimed (DECISIONS-LOG 2026-08-31) | Measured | +|---|---| +| "required status check" on `main` | `branch_protections` returns `[]`. `protected: false`, `enable_status_check: false`, `status_check_contexts: []`, `required_approvals: 0`, `user_can_push: true` | +| "runner `incluster-dind` online (ns gitea-actions)" | **Zero runners registered** at repo, org and site level | + +Both halves of that entry are false. Nothing enforces the gate, and the gate cannot run. +PR #1's two contexts (`gate / gate`, `gate / conformance`) sat `pending` for 14+ minutes with +nothing to execute them, and will sit there indefinitely. + +This is a **fail-open governance hole**, the same class as the two found on 2026-09-18 — and it +was invisible precisely because the log asserted the opposite. Every "the gate is enforcing" +statement since 2026-08-31 rested on an unverified claim. + +### Ordering trap — read before fixing + +**Do not enable required status checks until a runner is live.** Requiring +`gate / gate (pull_request)` with no runner deadlocks every PR permanently, including the PR +that would fix it. Sequence: runner first, verify the gate actually runs and passes on a real +PR, *then* turn on enforcement with the context name CI actually reported. + +Related: `gate.yml`'s `conformance` job is `if: false` by design (honest skip, never fake green), +but it still publishes a `gate / conformance (pull_request)` context. If that context is ever +listed as required, it deadlocks every PR for the same reason. Wire it or exclude it. + +### Delivery contract written down + +Added `CLAUDE.md` at the repo root — the first agent-facing delivery doc here. It states the +branch -> PR -> gate -> approval -> merge rule, records that enforcement is currently weaker than +the rule (so agents follow the rule rather than what they can get away with), and carries the +toolbox-gate invocation, the two git-worktree workarounds, the rc 0/1/3 contract, and the secret +handling rules. + +It also flags that **37 of 39 runbooks still instruct `git push origin main`**. Those were +accurate before the PR flow and are stale now. Correcting them is tracked, not done. + +### Also found, not fixed + +- `gate.yml`'s push trigger lists `feature/**`; this repo's branches use `feat/**`, so branch + pushes get no fast-feedback run. PR trigger is unaffected. +- The gitea-actions runner is GitOps-managed (`apps/wave-1/gitea-actions.yaml` + + `gitea-actions-runner-secret.sops.yaml`), so it is deployed but not registering — most likely a + stale registration token in that SOPS secret. Needs in-cluster diagnosis (operator). +- **Plex is still unclaimed and it was never checked** whether it is already claimed. Do that + before spending a token — `runbooks/plex-claim.md` §First commands. +- `runbooks/plex-claim.md` asserts a "~4-minute TTL" for a plex.tv claim token in four places, + **uncited**, and the whole stopwatch procedure is built on it. Upstream `pms-docker`'s README + says nothing about expiry. The number is plausible community folklore, not a measured fact. + The authoritative reading is the countdown rendered on plex.tv/claim at issue time. + +### Next steps + +1. **Operator:** diagnose the gitea-actions runner; get one registered. +2. **Operator:** enable branch protection on `main` — push blocked, approvals required — and only + then add the gate as a required status check. +3. Land `feat/plex-claim-sops-secret` (PR #1) once the gate can actually run. +4. Claim plex, after checking it is not already claimed. +5. Correct the 37 runbooks' `git push origin main` wording.