Batch 4 part 2 — kayak-deploy: digest-verified paired releases (S7, D2) - #190
Conversation
… activation (S7/4B, D2) The activation orchestrator the plan's S7 calls for, trimmed per decision D2: trust = a full 40-hex commit SHA reachable from each repo's protected branch (verified in a scratch bare clone) plus SHA-256 digests of the built wheel, dataset snapshot, and host-config fingerprint, all recorded in release.json — no signature machinery, and the deployer version is documented rather than negotiated. A release under $KAYAK_DEPLOY_ROOT/releases/<id>/ is self-contained: venv with the engine wheel, read-only dataset snapshot (git archive), contract-validated before anything else, non-secret runtime-config, docroot. The release id derives from wheel digest + dataset SHA + host-config fingerprint, so a host-config-only change is a distinct release. Activation: maintenance flag -> stop consumers -> sqlite .backup -> migrate -> all-or-nothing sync -> build into the release -> atomic symlink switch (ln + rename) -> optional health probe -> restart -> maintenance off; any failure rolls back the symlink, restores the DB backup if mutation began, restarts units, and preserves the scratch backup for manual recovery. --stage-only stops before any system mutation and is both the test mode and the recommended first run on a new host. Tests: ref-shape rejection (short/branch/tag/non-hex), missing-conf failure, unreachable-SHA rejection, and a slow end-to-end staging run (this repo's HEAD as the engine + the fixture dataset as a local git repo) asserting the venv, snapshot, runtime config, and digest manifest. bash-3.2-compatible so the suite runs on the dev Mac; prod needs only python3+pip3+git (hatchling fetched at wheel build). Activation is exercised on a real host by the Batch 4C clean-VM rehearsal (the planned virgin install), which also re-points nginx at $ROOT/current/docroot via the unit/vhost renderers. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…detached HEAD git rev-parse --abbrev-ref HEAD returns the literal 'HEAD' on CI's detached checkout, so ENGINE_BRANCH=HEAD made the deployer's single-branch clone fail (exit 128) in both branch-dependent tests. The module fixture now pushes HEAD into a scratch bare repo under a known 'test-main' branch, which works identically attached or detached. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…kout is shallow too git push from a shallow repository is refused, so the bare-repo push fixture failed on CI just like --abbrev-ref had. git archive HEAD works regardless of shallow/detached state; the staging test exercises the deployer's mechanics (clone, wheel build, snapshot, digests), not provenance, so a tree-identical single-commit repo is the right fixture. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…umed Third CI-environment difference: the test job's PATH leads with a uv-managed venv whose python has no pip module, so 'python3 -m pip wheel' failed. The deployer now needs only the stdlib venv/ensurepip: a scratch venv supplies pip for the wheel build and the release venv bootstraps its own, with an early capability check (clear error + KAYAK_DEPLOY_PYTHON escape hatch). Host requirement documented: python3-venv on Debian, no system pip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Adversarial review findings for PR #190 at current head
Verification run locally on the current PR head:
|
… identity, locked deps, virgin rollback All five PR #190 findings: P1 sidecars: new packaged kayak.db.sidecars + 'levels import-metadata' (the geom/gradient apply sync-metadata deliberately excludes; scripts/import_metadata.py now delegates to the same functions); activation runs it after sync, before build, so a sidecar-only dataset release reaches the DB. db/sidecars.py joins the writer-boundary ALLOWLIST with rationale. P1 quiesce: activation stops timers AND their services, then waits (max 120s) for the service set to drain before the pre-mutation backup — a timer stop alone leaves an in-flight oneshot writing under the backup/ migrate/sync. P1 identity: the non-secret runtime config is emitted during staging from the live host env and its digest joins the release id, so an /etc/kayak/env change mints a NEW release instead of reusing a stale runtime-config/docroot. Manifest records it. P2 locked deps: the engine commit now carries requirements-prod.lock (hash-pinned uv export, drift-checked in CI); release and staging venvs install with --require-hashes + the wheel with --no-deps, and the lock digest joins the release id — the same engine SHA installs identical bytes on any day. P2 virgin rollback: the symlink switch is tracked; a first-activation failure removes 'current', keeps maintenance mode, and does NOT restart consumers (there is no prior release to serve). Tests: import-metadata unit suite (both/only/rollback/partial/ malformed); staging test asserts the new digests and that a SITE_URL change yields a different release id. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All five findings addressed in 6f746c7: P1 — sidecar apply: new packaged P1 — quiesce: activation stops timers and their corresponding services, then waits (≤120 s) for the whole service set to drain before the backup; a still-active service aborts rather than mutating under it. P1 — release identity: staging now installs the engine into the scratch toolchain, validates the dataset, and emits the non-secret runtime config from the live host env before the release id is derived — the config digest (and the dependency-lock digest) join the id and the manifest, so an P2 — locked deps: the engine now carries P2 — virgin rollback: the symlink switch is tracked; a first-activation failure removes Tests: new |
|
Re-review of PR #190 at current head
Validation I ran locally:
|
|
Adversarial review for updated PR #190 at head Live context I checked:
Findings:
Verification performed on the current PR head:
|
…ty, locked build backend, virgin pre-switch rollback Second-round PR #190 findings: P1 serving gap: activation now REFUSES unless deploy.env sets SERVING_CUTOVER=yes — the flag the Batch 4C install/migration runbook sets when nginx root + FPM open_basedir actually point at $ROOT/current. Until then stage-only works and scripts/deploy.sh stays the deploy path; the deployer can no longer report success while users are served the legacy docroot. P1 config activation + secret boundary: activation installs the canonical /etc/kayak/runtime-config.json exactly the way deploy.sh does — emit-config --dry-run piped into the root kayak-install-runtime-config wrapper (root-only secrets merged, 0640 root:www-data), emitted with FINAL release paths. P1 identity stability + token retention: the release id's config digest is computed over a NORMALIZED view (staging-local path fields and ntfy/hc_* operational tokens excluded, keys sorted), so identical inputs mint the identical release id, the release-retained copy holds no dead scratch paths and no notification/healthcheck tokens, and the canonical secret-merged config lives only under /etc/kayak. P2 build backend: requirements-build.lock (hash-pinned hatchling chain via uv pip compile) is preinstalled into the build venv and the wheel builds with --no-build-isolation — no network resolution and no unpinned backend code at deploy time; its digest joins the release id and manifest. P2 virgin rollback: with no previous release, ANY activation failure — before or after the symlink switch — now leaves the host in maintenance with consumers stopped ('current' removed only if the switch happened). Tests: same-input staging mints the same release id (reuse path), the retained runtime-config has no scratch paths / no ntfy_topic / no hc_* / no token value, and the SITE_URL-change different-id assertion stays. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Zip entries carry checkout mtimes, so the same engine SHA hashed differently per clone — breaking the same-inputs => same-release-id property the re-review demanded. hatchling honors SOURCE_DATE_EPOCH; pin it to the commit's own timestamp (deterministic per SHA). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Its engine default is BASE_DIR-relative, which resolves inside the scratch build venv — the one remaining nondeterministic field in the release-identity digest (and a dead path in the retained copy). The DB location is host-runtime shape; PHP reads it from the canonical wrapper-installed /etc/kayak/runtime-config.json. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All seven findings from both second-round passes addressed (e8f04f0): P1 — serving gap: activation now refuses unless P1 — config activation + secret boundary: activation installs the canonical P1 — identity stability + token retention: the release id's config digest is computed over a normalized view — staging-local path fields ( P2 — build backend: new P2 — virgin rollback (pre-switch): with no previous release, any activation failure — before or after the symlink switch — now leaves the host in maintenance with consumers stopped ( Gate: pytest 1723 + the slow triple-staging run (~2 min), mypy, ruff, shellcheck — green. |
|
Re-review at Findings:
Local validation run: |
|
Adversarial re-review for PR #190 at head Live context checked:
Findings:
Validation performed locally on this head:
|
…-lock CI check, activation test PR #190 third-round findings (both passes): P1 config rollback: activation snapshots /etc/kayak/runtime-config.json before installing the new one and rollback() restores it on any post-install failure (failed switch/health), so a rollback never leaves the old release running with the failed release's config. P1 secret retention: the normalized release-retained config now drops any secret/password/token-shaped field (root-run staging emit unwraps SecretStr like turnstile_secret) in addition to path/ntfy/hc_* fields. P1 privilege model: one orchestrator — root for systemctl + the config wrapper; DB/build steps (backup/migrate/sync/import/build) run as KAYAK_APP_USER via runuser so root never creates app-owned-resource WAL sidecars. Read-only scratch staging stays as the orchestrator (the secret filter, not the uid, guards the retained copy). Required-when-root KAYAK_APP_USER documented; the root/runuser env propagation is validated in the 4C clean-VM rehearsal. P2 build-lock drift: requirements-build.lock is regenerated from pyproject.toml [build-system].requires and diffed in CI, so a backend change can't merge stale and only fail at deploy time. Tests: systemctl + config-installer are parameterized so a new slow activation test runs the full path unprivileged — first release activates, a second release with a failing health check rolls back the symlink, DB, AND runtime config to release 1. The staging test now also asserts TURNSTILE_SECRET never reaches the retained config. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All third-round findings addressed in 65b7f29 (both passes): P1 — config rollback (both reviewers): activation snapshots P1 — secret retention: the normalized release-retained config drops any P1 — privilege model: one orchestrator. Root performs P2 — build-lock drift: Gate: pytest 1723 + the slow deploy suite (10 tests, ~3.5 min incl. the new activation rollback test), mypy, ruff, shellcheck, both lock drift checks — green. |
|
Re-review at Findings:
Local validation run on this head: |
|
Adversarial re-review for PR #190 at head Live context checked:
Findings:
Validation performed locally on this head:
|
…ase verify (4th round) PR #190 fourth-round findings (both passes): P1 app-user DB boundary: the pre-activation backup now lands in an APP-OWNED scratch dir (runuser mktemp -d), since the orchestrator's 0700 mktemp -d is untraversable by the app user; the rollback DB restore runs through run_app too, so neither path recreates the root-owned WAL/SHM footgun. run_app's privilege decision and the runuser command are overridable (KAYAK_PRIVILEGED / KAYAK_RUNUSER) so the activation test exercises the privileged branch with a same-user shim — it now asserts backup/migrate/sync/import/build AND the rollback restore all cross the app-user boundary. P1 consumer cutover: SERVING_CUTOVER now means the whole host is cut over, and activation verifies each levels-running consumer unit's ExecStart points at $ROOT/current before mutating — otherwise the next pipeline/decimate/status run would execute the old checkout against the migrated DB. Gate doc + deploy.env.example updated. P2 reused-release verify: a reused release dir is now fully verified — every digestable retained artifact (wheel, prod lock, runtime-config) against both the manifest and the recomputed inputs, the dataset tar digest, and a venv liveness check; the dataset is re-extracted from the freshly-verified tar. Any mismatch fails closed (test corrupts a retained lock and asserts refusal). Also fixes an EXIT-trap gotcha the new app-scratch introduced: cleanup ended on a failing test expression, leaking exit 1 into stage-only (caught by the staging test). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
All fourth-round findings addressed in 97a2987 (both passes): P1 — app-user DB boundary (real bug): the pre-activation backup now lands in an app-owned scratch dir ( P1 — consumer cutover: P2 — reused-release verify: a reused release dir is now fully verified — every digestable retained artifact (wheel, prod lock, runtime-config) against both the manifest and the recomputed inputs, plus the dataset-tar digest and a venv liveness check; the dataset is re-extracted from the freshly-verified tar. Any mismatch fails closed (new test corrupts a retained lock and asserts refusal). Bonus the new tests caught: adding the app-scratch introduced an EXIT-trap gotcha ( Gate: pytest 1723 + the full deploy suite (11 tests now, incl. privileged-mode activation rollback + corrupted-reuse refusal, ~4 min), mypy, ruff, shellcheck, both lock drift checks — green. (Local runs need |
Adversarial review — PR #190 (kayak-deploy, S7/D2)Reviewed against the live host's actual configuration, not just the diff. The design is sound and the engineering is careful (atomic switch, app-user DB boundary, hash-locked deps, fail-closed reuse, reproducible wheels). The PR is also genuinely inert today — The findings below are about what happens when 4C actually points this at the live host. The recurring theme: the tests pass only because they inject into the process env the very values the live host does not supply that way, so the staging/activation env model is effectively unverified against reality. P1 —
|
…, type-based secret exclusion PR #190 live-host review (reviewed against the running server): P1 host-env model: on the WKCC host /etc/kayak/env holds only KAYAK_HOME; the real config (SQLITE_PATH/SITE_URL/DATASET_DIR) lives in the app user's ~/.config/kayak/.env, which the units load via a second EnvironmentFile=. The deployer now sources BOTH (KAYAK_HOST_ENV + KAYAK_APP_ENV/the app user's .config, resolved via getent) with set -a so subprocesses inherit them — so staging emit-config sees SITE_URL and DB_PATH resolves, run directly as root or via sudo (no SUDO_USER dependency). The false 'standard host environment' comment is fixed; SQLITE_PATH/SITE_URL absence fails early and clearly. P2 cutover gate: replaced the leaky *levels* substring (which missed kayak-audit-gauges, a python-run engine consumer) with: every consumer service must run from $ROOT/current unless explicitly in KAYAK_HOST_UNITS (default: the backup units). P2 release pruning: keep KAYAK_KEEP_RELEASES (default 5) most-recent releases after a successful activation, always retaining current + previous (rollback needs PREV_TARGET); each release carries a full venv so this bounds VPS disk growth. Secret exclusion: emit-config gains --exclude-secrets (TYPE-based: drops every SecretStr field, not a name heuristic), which the deployer uses for the normalized/retained config so a future secret field can't leak by name; the name filter stays as a second line of defense. Nit: the fetch_and_verify comment no longer claims a non-existent explicit fetch. Tests: the activation test now resolves SITE_URL+SQLITE_PATH from a KAYAK_HOST_ENV FILE (exercising the real sourcing the live review flagged as untested), new prune test (3 activations, KEEP=0, asserts the oldest pruned + current/previous kept), and an emit-config --exclude-secrets type-based test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The release-activation cutover used the idiom
ln -s "releases/$RELEASE_ID" "$ROOT/current.new"
mv -f "$ROOT/current.new" "$ROOT/current"
which is correct only on the FIRST activation, when $ROOT/current does
not yet exist. On every later deploy `current` is a symlink to a
directory, and both GNU and BSD `mv` dereference such a destination:
`mv -f current.new current` moves current.new INTO releases/<old-id>/,
leaving `current` pointed at the OLD release. Build/migrate/sync/
config-install all succeed and the script exits 0 — but nothing serves
the new release. The new prune step then reads `cur` from the stale
symlink, so the freshly-built (now unreferenced) release matches neither
current nor previous and is garbage-collected: a no-op cutover that also
deletes the release it was meant to activate.
Replace both the forward switch and the rollback restore with an
`atomic_relink` helper that repoints the symlink WITHOUT dereferencing
it: GNU `mv -T`/--no-target-directory, BSD/macOS `mv -h`, each an atomic
rename(2). Try GNU, then BSD, then a non-atomic remove+move only if `mv`
supports neither flag.
Caught by test_activation_prunes_old_releases (added with the pruning
feature): three real activations produce three distinct release ids, yet
`current` stayed pinned to release 1 and only release 1 survived on disk.
Now green, alongside the existing rollback activation test (which also
performs a second switch over an existing `current`).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The prod host's /tmp is a ~1 GB tmpfs. kayak-deploy staged into `mktemp -d` (i.e. /tmp): the wheel build + two venv installs (a few hundred MB) plus the app-owned pre-activation DB backup (the live DB is ~650 MB) overflow it, and the deploy dies mid-stage with ENOSPC — exactly when it is about to mutate production. No unit test could surface this; it took the Batch 4C clean-VM rehearsal (the test VM mirrors prod's small /tmp tmpfs). Default the scratch base to KAYAK_DEPLOY_TMPDIR (= $KAYAK_DEPLOY_ROOT/.staging, always real disk) for BOTH the orchestrator scratch and the app-owned DB-backup scratch. The app scratch is carved from the same real-disk base (0755 so the app user can traverse in) and chowned to the app user, instead of the app user's own `mktemp -d`. Use the portable `mktemp -d <dir>/X...` template form (`-p` is GNU-only) so the macOS unit tests still pass. Validated on the clean-VM clone: stage-only AND a full activation (the 650 MB DB backup) both succeed with /tmp at 82% and no TMPDIR set; /tmp usage stays flat. deploy.env.example documents the var and the sourced-shell quoting rule for multi-word values. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What's new since the last review round + how it was validated on real hardwareHead is now The clean-VM rehearsal (the main new evidence)A throwaway VM was built into a faithful current-live copy (same Debian 13.5, same packages, the live DB copy — 432 reaches, the two repos at the live SHAs, real nginx + PHP-FPM + the 14 systemd units serving the site over HTTPS). Then the full live → paired-release cutover was run on it via
This is real root + real systemd + a real second service user + GNU coreutils — the things the unit tests could only simulate. Two bugs the rehearsal found (and fixed)
Findings deferred to follow-on PRs (not in this PR)
The cutover itself is still gated behind |
|
Adversarial review, including the current live-server context: FindingsP1 - Root deployer executes the app user's dotenv as shell
Please parse the needed runtime keys as data with a narrow allowlist, or run the env-loading/config-emission portion entirely under the app user and pass only sanitized values back. Do not P1 - The default quiesce/cutover unit set misses a live DB consumer The live host currently has This is not just theoretical: P2 - Reusing an existing release mutates the release before stage-only/gates/quiesce In the reuse branch, Reuse should be read-only until activation quiesces consumers. Verify against scratch artifacts without deleting the existing release's dataset, or restage into a new temp dir and atomically replace only when it is safe. Verification
|
Adversarial review — PR #190 (kayak-deploy paired-release orchestrator)Reviewed What I verified against the live host (claims that hold up ✅)
FindingsF1 — Medium (defense-in-depth gap): the default
The cutover-verification loop iterates only over F2 — Low (latent, conditional on root umask): the umask-077 defense is incomplete. F3 — Low (divergent-parser footgun): the deployer shell- F4 — Nit: the host-config fingerprint is inert on this host. F5 — Nit: F6 — Nit/operational: a deploy overlapping the hourly Bottom line: solid, genuinely well-hardened work — the inert-until-cutover gating means this merges with no risk to the live site. F1 is the one I'd want addressed (or explicitly delegated to the 4C runbook) before the first real activation, since the consumer set is what stands between a migrate/sync and old code reading the post-migration DB. F2–F3 are latent footguns worth a cheap fix; F4–F6 are notes for the cutover runbook. Reviewed against the live WKCC host on 2026-06-13. 🤖 Generated with Claude Code |
…k, units) Two live-server adversarial reviews of a22585b (codex/gpt-5.5 + claude/ opus). Both: no merge blocker (inert until SERVING_CUTOVER=yes). These are the real code-level findings, fixed before merge: - SECURITY (codex P1 / claude F3): the deployer no longer shell-`source`s the app-user-owned ~/.config/kayak/.env as root — that was a root code-execution + deploy-control-override surface (it could set ENGINE_REPO, KAYAK_SYSTEMCTL, …) introduced in be43a93. Read only the allowlisted data keys (SITE_URL/SQLITE_PATH/DATASET_DIR/OUTPUT_DIR) as data, only when the root-owned env hasn't already set them. /etc/kayak/ env (root-owned) is still sourced. - reuse READ-ONLY (codex P2): the reuse branch no longer rm's + re-extracts the (possibly live) release's dataset before the stage-only exit / quiesce — it diff-verifies the on-disk dataset against the tar from scratch and fails closed on mismatch. Upholds "stage-only stops before mutation." - umask 022 (claude F2): consistent dir perms so $ROOT/releases/<id> stays app-user-traversable even under a root umask of 077 (the .staging chmod alone was inconsistent). - kayak-healthcheck.timer added to the default KAYAK_UNITS + example (a live DB consumer the cutover gate couldn't see); the 4C runbook owns the COMPLETE enumeration. Tests: new test_app_env_keys_read_as_data_not_sourced (a malicious app .env line must not execute, the allowlisted key is read as data, a non-allowlisted deploy control can't override); a reuse-is-read-only assertion; and the slow activation tests stage on a real-disk root (/var/tmp) so the module is runnable on a prod-shaped host (964 MB /tmp tmpfs) — codex couldn't run it before. `_run` gained None=remove-key for hermetic envs. Deferred to the 4C runbook (cutover-time): the robust consumer-set derivation (recap/heartbeat/config-drift), HEALTH_URL required for the first cutover, and scheduling clear of the :12 pipeline. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Both live-server reviews addressed in Fixed in
|
Re-review of 530b4a0 — fixes verified against the live hostChecked the hardening commit against the running WKCC host. All four changes hold up, and the one finding that mattered is fully closed. ✅ F3 / codex P1 (the security one) — closed and verified RCE-safe. Replacing the
✅ F2 (umask) — fixed correctly. ✅ codex P2 (reuse read-only) — good catch & fix. The old ◐ F1 (consumer set) — highest-value piece taken, rest appropriately deferred. Notes I'm satisfied are covered, not gaps: SITE_URL flowing from the app-env allowlist → Bottom line: the real finding (root source-injection) is closed and I've verified it against this host's actual Re-reviewed against the live WKCC host, 2026-06-13. 🤖 Generated with Claude Code |
…lure (errtrace) Adversarial review of #192 surfaced a chain of rollback gaps. Fixing finding #1 (arm DOCROOT_BUILT before the build) exposed a deeper, pre-existing PR #190 bug: the rollback never ran for the activation mutation steps at all. - **errtrace (the big one).** The deployer ran `set -euo pipefail` without `-E`. Every DB/build mutation runs through `run_app()` (a function), and without errtrace a failure INSIDE a function aborts under `set -e` WITHOUT triggering the `ERR` trap. So a failed `run_app … migrate/sync/import/build` exited with the DB already mutated, consumers stopped, maintenance still on — and NO rollback (only the EXIT scratch-cleanup). `set -Eeuo pipefail` makes those failures hit `trap rollback ERR`. The trap is armed for the activation phase only (staging has no ERR trap, so `-E` is inert there); atomic_relink's `mv` probes sit in `if` conditions, which stay exempt. Surfaced writing finding #1's test — the docroot-rebuild fix is moot if rollback doesn't run. - **#1: DOCROOT_BUILT armed before the build, not after.** `build` writes the live docroot in place (stage → per-file rename → orphan sweep), so a failure mid-write leaves it mixed yet exits non-zero. The flag must mean "build started" (docroot may have changed) so rollback rebuilds; a rebuild when it was in fact untouched is harmless/idempotent. - **#3: normalize an absolute `current` symlink target.** The rebuild used `$ROOT/$PREV_TARGET`; a manual-recovery absolute `current` made that `/opt/kayak//opt/...`. Compute `PREV_DIR` with an absolute/relative case split. - **nit #4: keep the rollback rebuild's stderr** (to `$SCRATCH/docroot-rebuild.err`, retained) instead of discarding it — the recovery path is where the reason matters most. - **#2 (ACL): commit the shared-docroot web ACL** to deploy/SETUP.md's cutover section — the recursive + default `u:www-data:rX` on `/var/cache/kayak/docroot` the deployer doesn't grant; without the `-d` default ACL each freshly built page 403s. The nginx-root / FPM-open_basedir / unit verification gate stays a 4C-renderer deliverable (it owns those templates). Tests: two new slow tests — build mutates the docroot then exits 1 → rollback rebuilds (proves the flag-before-build + errtrace fix); and an absolute `current` target → rebuild reads the normalized dir. Full slow suite 5 passed, non-slow 1725 passed, ruff/format/shellcheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
) This is what actually retires #228. `[build-system].requires` was unbounded, requirements-build.lock is `uv pip compile`d from it, and CI's drift check recompiles against live PyPI every run and diffs — so hatchling 1.31.0 shipping reddened every open PR, whatever its diff. #228 regenerated the lock by hand; that bought time, it didn't fix anything. A range does not fix it either, which is worth stating because it is the obvious reach: `uv pip compile` resolves the NEWEST version a specifier admits, so `>=1.27,<2` re-resolves the day 1.32.0 lands and the lock is stale again. Measured, not assumed: hatchling>=1.27,<2 -> 1.31.0 (newest in range — drifts again) hatchling>=1.27,<1.31 -> 1.30.1 (proves it takes the max, not a pin) hatchling==1.30.1 -> 1.30.1 (stable) Only `==` makes compile-then-diff idempotent, which is the property the check needs. It also fits what this feeds: kayak-deploy builds the wheel with --no-build-isolation from the hashed lock (#190), so the backend version is part of the artifact and pinning it is the point, not a wart. The cost is that nothing watches this line — no Dependabot ecosystem reads [build-system].requires, so hatchling now goes stale until bumped by hand. A stale-but-working build backend is a strictly better failure than an unpredictable red CI, and the comment carries the two-line bump recipe. Lock unchanged: #228 already put 1.31.0 there, so this only removes the mechanism that made that a recurring chore. Drift check verified idempotent across repeated runs; wheel builds clean. Claude-Session: https://claude.ai/code/session_01RR6LwVdry9fmXkEhkC9Cbi Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Second slice of finishing-plan Batch 4: the paired-release activation orchestrator (
deploy/kayak-deploy.sh, installed as/usr/local/sbin/kayak-deployby the 4C runbook). Nothing on the current host consumes it yet — fully inert until the 4C cutover re-points nginx and installs it.Design (plan §S7, trimmed per accepted decision D2)
release.json. Deployer version is documented (KAYAK_DEPLOY_VERSION=1), not negotiated./opt/kayak/releases/<id>/: venv + wheel, read-only dataset snapshot (contract-validated with the staged engine before anything else), non-secretruntime-config.json, docroot. Release id = digest(wheel ⊕ dataset SHA ⊕ host-config fingerprint) — a host-config-only change is a distinct release.sqlite3 .backup→migrate→ all-or-nothingsync-metadata→ build into the release → atomic symlink switch (ln + rename) → optional health probe → restart → maintenance off. Any failure: symlink back to the previous release, restore the DB backup if mutation began, restart units, preserve the scratch backup for manual recovery.--stage-onlystops before any system mutation — the test mode and the recommended first run on any host.Tests
Ref-shape rejection, missing-conf failure, unreachable-SHA rejection (fast), plus a slow end-to-end staging run using this repo's HEAD as the engine and the fixture dataset as a local git repo — asserts the venv, snapshot, runtime config, digest manifest, and that stage-only never creates
current. bash-3.2-compatible (suite runs on the dev Mac); prod needs only python3+pip3+git.The activation phase gets its first real exercise in the Batch 4C clean-VM rehearsal (the planned virgin install), before the prod cutover.
Gate: ruff + format, pytest (1718 + the slow staging test), mypy, shellcheck — green.
🤖 Generated with Claude Code