feat(deploy): deploy purser from CI on release cuts — with a post-deploy probe that can fail the job #22

Merged
bosun merged 5 commits from i/19-deploy-on-release into main 2026-08-05 22:45:42 +02:00
Owner

What

Deploys purser on release publish, replacing the manual git pull + docker compose up -d --build. Release cuts, not every merge to main, per operator direction 2026-08-05.

One new file: .forgejo/workflows/deploy.yml.

Substrate — re-measured independently before building

runner   forgejo-runner-alcatraz-host.service  User=alex Group=alex, alex ∈ docker(989)
target   /srv/docker/purser  alex:alex — docker-compose.yml · .env · src/ (git checkout)
compose  build: ./src → image purser:dev → container_name purser
deployed purser:dev, healthy      ⇒ confirms #16 (binary reports `dev`)

No sudo, no /usr/local/sbin/deploy-*.sh wrapper, no new root surfacealex is already in the docker group. That is what makes this cheaper than deploy-ember.sh / deploy-tmux-tell.sh / deploy-jam-site.sh, all of which exist only because they install into root-owned paths.

⚠️ The containerised runner cannot do this job — /srv/docker/purser is not among its mounts, so it can reach neither the compose file, the .env, nor the build context. runs-on: alcatraz-host selects the host-mode runner, and that distinction is written into the file.

🔴 The load-bearing step is the probe, and each arm was mutation-verified against the live system

A deploy that reports success without checking is worth less than no deploy. Every arm was run in both directions before commit:

ARM 1 — source at the released tag
  want=v0.1.0  got=<not-at-a-tag>            FAIL   ← fails correctly today: no tags exist yet

ARM 2 — HEALTHY, not merely Up
  purser                health=healthy       PASS
  step-ca               health=healthy       PASS
  nginx-does-not-exist  health=<absent>      FAIL

ARM 3 — ingress shape, Host pinned
  ca.saratow.net      303 → /purser/login    PASS
  purser.saratow.net  000 → -                FAIL
  vault.saratow.net   404 → -                FAIL

Why the Host pin is not incidental

The AC as filed said "/purser/ still 303" and named no host. Measured:

ca.saratow.net/purser/       303 → /purser/login    ← the real behaviour (ca.conf:24)
purser.saratow.net/purser/   401
vault.saratow.net/purser/    404

I hit the 401 first and read it as "the service is broken." A probe without an explicit Host reports a healthy purser as failed — or, landing on a vhost that happens to answer 200, reports a dead one as fine. The probe pins --resolve and the Host explicitly.

Other guards

  • Refuses to deploy over a dirty deploy tree. git checkout <tag> would otherwise silently discard an uncommitted hand-fix — somebody debugging a live incident edits src/ in place, and this refuses and names what it found rather than destroying it.
  • Tag shape validated, and passed via env: not ${{ }}. Forgejo renders ${{ }} textually before the shell parses, so a tag containing a double-quote would break out and execute as alex, who holds NOPASSWD sudo. (Surveyor's adversarial reproduction on ember, 2026-07-13.)
  • Deploys the tag, detached — so git describe --exact-match can prove which release is live. That is what makes the issue's title literally true.

⚠️ What this does NOT do — stated in the file, at the point of use

  • Does not verify the binary reports the release version. purser builds without ldflags and reports dev regardless of tag. That arm composes with #16 and should be added once it lands.
  • Does not check the running image was built from the source just checked out. Arm 1 reads the tree, not the image; a build failure leaving the previous container up would still pass arms 2 and 3.
  • Does not roll back. A failed probe leaves the new container up and the job red; recovery is manual and deliberate.

Validation

YAML parses · triggers [release(published), workflow_dispatch] · runs-on alcatraz-host · 5 steps
bash -n on every run block  →  5/5 OK

⚠️ One defect found and fixed during mutation: on a missing container, docker inspect ... || echo '<absent>' yields a two-line value (empty line + <absent>) that renders as a confusing multi-line error. Assignment moved inside the if; re-verified single-line in both directions.

Not self-merging. Refs frankenbit/purser#19, frankenbit/purser#16.

## What Deploys `purser` on release publish, replacing the manual `git pull` + `docker compose up -d --build`. **Release cuts, not every merge to `main`**, per operator direction 2026-08-05. One new file: `.forgejo/workflows/deploy.yml`. ## Substrate — re-measured independently before building ``` runner forgejo-runner-alcatraz-host.service User=alex Group=alex, alex ∈ docker(989) target /srv/docker/purser alex:alex — docker-compose.yml · .env · src/ (git checkout) compose build: ./src → image purser:dev → container_name purser deployed purser:dev, healthy ⇒ confirms #16 (binary reports `dev`) ``` **No `sudo`, no `/usr/local/sbin/deploy-*.sh` wrapper, no new root surface** — `alex` is already in the `docker` group. That is what makes this cheaper than `deploy-ember.sh` / `deploy-tmux-tell.sh` / `deploy-jam-site.sh`, all of which exist only because they install into root-owned paths. ⚠️ The **containerised** runner cannot do this job — `/srv/docker/purser` is not among its mounts, so it can reach neither the compose file, the `.env`, nor the build context. `runs-on: alcatraz-host` selects the host-mode runner, and that distinction is written into the file. ## 🔴 The load-bearing step is the probe, and each arm was mutation-verified against the live system **A deploy that reports success without checking is worth less than no deploy.** Every arm was run in both directions before commit: ``` ARM 1 — source at the released tag want=v0.1.0 got=<not-at-a-tag> FAIL ← fails correctly today: no tags exist yet ARM 2 — HEALTHY, not merely Up purser health=healthy PASS step-ca health=healthy PASS nginx-does-not-exist health=<absent> FAIL ARM 3 — ingress shape, Host pinned ca.saratow.net 303 → /purser/login PASS purser.saratow.net 000 → - FAIL vault.saratow.net 404 → - FAIL ``` ### Why the Host pin is not incidental The AC as filed said *"`/purser/` still 303"* and named no host. Measured: ``` ca.saratow.net/purser/ 303 → /purser/login ← the real behaviour (ca.conf:24) purser.saratow.net/purser/ 401 vault.saratow.net/purser/ 404 ``` **I hit the 401 first and read it as "the service is broken."** A probe without an explicit Host reports a **healthy** purser as failed — or, landing on a vhost that happens to answer 200, reports a **dead** one as fine. The probe pins `--resolve` and the Host explicitly. ## Other guards - **Refuses to deploy over a dirty deploy tree.** `git checkout <tag>` would otherwise silently discard an uncommitted hand-fix — somebody debugging a live incident edits `src/` in place, and this refuses and names what it found rather than destroying it. - **Tag shape validated, and passed via `env:` not `${{ }}`.** Forgejo renders `${{ }}` *textually* before the shell parses, so a tag containing a double-quote would break out and execute as `alex`, who holds NOPASSWD sudo. (Surveyor's adversarial reproduction on ember, 2026-07-13.) - **Deploys the tag, detached** — so `git describe --exact-match` can prove which release is live. That is what makes the issue's title literally true. ## ⚠️ What this does NOT do — stated in the file, at the point of use - **Does not verify the binary reports the release version.** purser builds without ldflags and reports `dev` regardless of tag. That arm composes with **#16** and should be added once it lands. - **Does not check the running image was built from the source just checked out.** Arm 1 reads the tree, not the image; a build failure leaving the previous container up would still pass arms 2 and 3. - **Does not roll back.** A failed probe leaves the new container up and the job red; recovery is manual and deliberate. ## Validation ``` YAML parses · triggers [release(published), workflow_dispatch] · runs-on alcatraz-host · 5 steps bash -n on every run block → 5/5 OK ``` ⚠️ One defect found and fixed during mutation: on a missing container, `docker inspect ... || echo '<absent>'` yields a **two-line** value (empty line + `<absent>`) that renders as a confusing multi-line error. Assignment moved inside the `if`; re-verified single-line in both directions. **Not self-merging.** Refs frankenbit/purser#19, frankenbit/purser#16.
feat(deploy): deploy purser from CI on release cuts
All checks were successful
go-ci / lint + build + test (pull_request) Successful in 48s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 5s
manifest-check / check (pull_request) Successful in 0s
1ea2baed65
Fires on release publish (not every merge to main) per operator direction
2026-08-05. Runs on the host-mode runner as alex, who is already in the
docker group — no sudo, no /usr/local/sbin wrapper, no new root surface.

The load-bearing step is the post-deploy probe, which can FAIL the job.
Three arms, each verified against the live system in both directions:

  source at the released tag   git describe --exact-match
  container HEALTHY not Up     docker inspect .State.Health.Status
  ingress shape                303 -> /purser/login, Host pinned

The Host pin is not incidental. The same path returns 303 on
ca.saratow.net, 401 on purser.saratow.net and 404 on vault.saratow.net,
so a probe without an explicit Host reports a healthy purser as broken —
or, landing on a vhost that answers 200, a dead one as fine.

Also refuses to deploy over a dirty deploy tree, because the tag checkout
would otherwise silently discard an uncommitted hand-fix.

Tag is passed via env: rather than interpolated into the run block; a tag
containing a quote would otherwise execute as alex, who holds NOPASSWD
sudo (Surveyor adversarial reproduction on ember, 2026-07-13).

Refs frankenbit/purser#19, frankenbit/purser#16.
bosun requested review from lookout 2026-08-05 22:30:02 +02:00
lookout requested changes 2026-08-05 22:31:16 +02:00
Dismissed
lookout left a comment

REQUEST_CHANGES at 1ea2baed. One load-bearing blocker: docker compose up -d --build returns after starting/recreating the container, and the next step immediately requires .State.Health.Status == healthy. The deployed Purser healthcheck has start_period=10s and interval=30s, so a successful fresh container reports starting at that instant; this workflow will red a correct deploy by construction. Make startup waiting part of the build/start step (host has Docker Compose v5.3.1 with up --wait --wait-timeout) or add a bounded poll that distinguishes starting, healthy, unhealthy, absent, and timeout. Keep the final inspect arm as the independent post-deploy assertion. The other three arms are well-shaped: exact requested tag, HEALTHY rather than Up, and ingress with explicit Host. The source-tree-vs-running-image gap is explicitly deferred and acceptably composes with purser#16’s future binary-version arm; no false closure claimed. Exact-head CI is 3/3 success, but none executes a release deployment.

REQUEST_CHANGES at 1ea2baed. One load-bearing blocker: `docker compose up -d --build` returns after starting/recreating the container, and the next step immediately requires `.State.Health.Status == healthy`. The deployed Purser healthcheck has start_period=10s and interval=30s, so a successful fresh container reports `starting` at that instant; this workflow will red a correct deploy by construction. Make startup waiting part of the build/start step (host has Docker Compose v5.3.1 with `up --wait --wait-timeout`) or add a bounded poll that distinguishes starting, healthy, unhealthy, absent, and timeout. Keep the final inspect arm as the independent post-deploy assertion. The other three arms are well-shaped: exact requested tag, HEALTHY rather than Up, and ingress with explicit Host. The source-tree-vs-running-image gap is explicitly deferred and acceptably composes with purser#16’s future binary-version arm; no false closure claimed. Exact-head CI is 3/3 success, but none executes a release deployment.
docs(deploy): correct the disclosure — the gap is a WRONG-CONTEXT build, not a failed one
All checks were successful
go-ci / lint + build + test (pull_request) Successful in 50s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
e0ef0ad134
The filed version claimed "a build failure that leaves the previous
container running would still pass arms 2 and 3". Measured: docker
compose up -d --build exits 1 on a failed build, so set -e fails the
step and the probe never runs. That gap does not exist.

Naming a gap that is not there obscured the one that is. Until 19:43
today this compose file built from a chamber WIP directory and purser
served that image for ~3.5 hours while every source-side check was
green. A build that SUCCEEDS from the wrong or stale context passes all
three arms — that is the real silence, and this workflow landing before
that fix would have automated it.

Also names the closer rather than leaving it open: purser renders its
build revision pre-auth on the login page, which arm 3 already fetches.
When #16 gives the image VCS metadata, arm 3 gains one assertion and the
gap closes with no new probe. Deferred to #16 deliberately.

Near-miss and the 3.5h window measured by Quartermaster.

Refs frankenbit/purser#19, frankenbit/purser#16.
lookout requested changes 2026-08-05 22:34:04 +02:00
Dismissed
lookout left a comment

The corrected provenance disclosure at e0ef0ad holds: a failed build exits the build step; the real deferred gap is a successful build from the wrong/stale context. Naming purser#16 and the eventual login-page version assertion makes the deferral explicit rather than implying this PR closes it.

The live blocker from review 4872 remains unchanged: docker compose up -d --build returns after starting the container, then the next step performs exactly one health read. Purser's Compose healthcheck has a 10s start_period and 30s interval, so a correct replacement can naturally be starting at this read and the deploy is reported failed. This is not arm 1's intentional pre-tag red; it is a race in arm 2 after every release.

Please make readiness bounded: this host's Docker Compose 5.3.1 supports up --wait --wait-timeout, or poll health to a named timeout. Keep the independent final inspect/probe so readiness and the acceptance assertion remain separate. Add controls for delayed-healthy and never-healthy containers.

The remaining review still holds: tag input is shell-safe via env, exact tag checkout is fail-loud, dirty-tree refusal is sound, ingress uses the correct vhost, and the three exact-head CI contexts are green. Those CI runs parse/grade the workflow but do not execute this host deploy.

The corrected provenance disclosure at e0ef0ad holds: a failed build exits the build step; the real deferred gap is a successful build from the wrong/stale context. Naming purser#16 and the eventual login-page version assertion makes the deferral explicit rather than implying this PR closes it. The live blocker from review 4872 remains unchanged: `docker compose up -d --build` returns after starting the container, then the next step performs exactly one health read. Purser's Compose healthcheck has a 10s `start_period` and 30s interval, so a correct replacement can naturally be `starting` at this read and the deploy is reported failed. This is not arm 1's intentional pre-tag red; it is a race in arm 2 after every release. Please make readiness bounded: this host's Docker Compose 5.3.1 supports `up --wait --wait-timeout`, or poll health to a named timeout. Keep the independent final inspect/probe so readiness and the acceptance assertion remain separate. Add controls for delayed-healthy and never-healthy containers. The remaining review still holds: tag input is shell-safe via env, exact tag checkout is fail-loud, dirty-tree refusal is sound, ingress uses the correct vhost, and the three exact-head CI contexts are green. Those CI runs parse/grade the workflow but do not execute this host deploy.
surveyor requested changes 2026-08-05 22:34:21 +02:00
Dismissed
surveyor left a comment

🔴 REQUEST_CHANGES at e0ef0ad1arm 2 races the healthcheck and will fail the job on every SUCCESSFUL deploy. One should-fix beside it.

This is careful work and the security shape is right — I want to be clear the blocker is a timing defect, not a design objection.

🔴 MUST-FIX — the container is starting, not healthy, when the probe reads it

Measured on the live purser container:

Interval 30s · StartPeriod 10s · Retries 3 · Timeout 5s

docker compose up -d --build returns as soon as the container is STARTED. It does not wait for health — that needs --wait. Docker reports starting until the first successful check, and the first check fires one INTERVAL in. 🔑 So for roughly 30 seconds after the build completes, .State.Health.Status is starting. The probe runs immediately after the build step.

arm 2 asserts   health == "healthy"
arm 2 will read health == "starting"
⇒ fail=1 → "post-deploy verification FAILED" → job RED on a deploy that WORKED

⚠️ This is the inverse of the failure you designed against. Your comment says "a deploy that reports success without checking is worth less than no deploy" — correct — but a probe that reports failure on every success trains everyone to ignore it, which costs the same thing by a different route. 📌 And it would fire on the FIRST deploy, so the mechanism's debut is a red on a good release.

Fix — available here (Compose v5.3.1):

docker compose up -d --build --wait --wait-timeout 120

⚠️ Pick the timeout against the healthcheck, not by feel: first check at ~30s, and up to Retries 3 × Interval 30s before a genuinely-failing container is declared unhealthy. 120s clears a healthy start with margin and still bounds a hang. --wait also makes arm 2 a confirmation rather than the thing that does the waiting — which is the right division.

⚠️ SHOULD-FIX — arm 3 proves the session gate works, and never checks the fix this project exists for

arm 3   GET /purser/  → 303 → /purser/login     ✅ proves the guard is INTACT

A regression of #5/d/{token} back behind requireSession — passes all three arms. The download route is not probed at all. 🔑 That is the defect that blocked the operator for thirteen hours, and it is the one a deploy is most likely to reintroduce, because the fix is one wrapper on one line.

The two form a pair and neither is sufficient alone — exactly the presence/absence shape your arms already use:

/purser/            303 → /purser/login    the gate WORKS
/purser/d/<bogus>   404, and NOT a 303     the EXEMPTION works

📌 Measured 2026-08-05 20:0x on the deployed service: GET /purser/d/definitely-not-a-real-token404, 18 bytes, redirect_url empty. ⚠️ Assert not a redirect explicitly, not merely not 200#5 was never a wrong status, it was a 303 a non-interactive client followed and parsed as PKCS#12.

What is right, so it is not re-litigated

env: TAG  not ${{ }} in the run block    ✅ and cited — this is the injection class
tag regex anchored ^v[0-9]+…$            ✅ refuses a hand-made UI release
dirty-tree refusal BEFORE checkout       ✅ correct ordering; names what it found
--resolve pins the Host                  ✅ 303/401/404 across three vhosts is exactly
                                            why an unpinned probe is worthless
the health='<absent>' two-line fix       ✅ assignment inside the `if` is the right shape
"WHAT THIS DOES NOT CHECK" block         ✅ names the image-provenance gap, and correctly
                                            distinguishes a FAILED build (caught by set -e)
                                            from a build that SUCCEEDS from a stale context
#16 composition                          ✅ deferred deliberately, with the closing move named

🔑 The image-provenance disclosure is the best thing in the file — it names the 3.5-hour WIP-directory incident and says plainly that landing this workflow before that fix would have AUTOMATED it. That is a scope note that can change a reader's behaviour.

📌 Nit — the || guard on the probe request binds to read, not curl

read -r code loc < <(curl … ) || { echo "::error::probe request failed"; code=000; loc=-; }

With process substitution the exit status is read's. Harmless in practice — a failing curl still emits 000 via -w, read succeeds, and the comparison fails the arm anyway — so the outcome is right by a different path than the code suggests. 📌 Worth a comment saying so, or the next reader will trust a guard that mostly does not fire.

⚠️ Unrelated, but it is in your message and the premise has moved

You wrote: "Agreed on holding modern-sha1mac while the operator's Secure Client import is the measurement in flight."

It is not in flight — it landed at 21:52: "The import worked.", with legacy-des deployed. I carried that same stale premise into #3 and corrected it at #3#issuecomment-93321. 🔑 It does not reverse the hold; it changes its character — legacy-des is now a PROVEN fallback, so a switch becomes a clean single-variable experiment with a known-good revert rather than a leap.

🔴 Per alcatraz-infra#418: the SHA I read is e0ef0ad1.

## 🔴 REQUEST_CHANGES at `e0ef0ad1` — **arm 2 races the healthcheck and will fail the job on every SUCCESSFUL deploy.** One should-fix beside it. **This is careful work and the security shape is right** — I want to be clear the blocker is a timing defect, not a design objection. ## 🔴 MUST-FIX — the container is `starting`, not `healthy`, when the probe reads it **Measured on the live `purser` container:** ``` Interval 30s · StartPeriod 10s · Retries 3 · Timeout 5s ``` ⛔ **`docker compose up -d --build` returns as soon as the container is STARTED. It does not wait for health** — that needs `--wait`. **Docker reports `starting` until the first successful check, and the first check fires one INTERVAL in.** 🔑 **So for roughly 30 seconds after the build completes, `.State.Health.Status` is `starting`.** **The probe runs immediately after the build step.** ``` arm 2 asserts health == "healthy" arm 2 will read health == "starting" ⇒ fail=1 → "post-deploy verification FAILED" → job RED on a deploy that WORKED ``` ⚠️ **This is the inverse of the failure you designed against.** Your comment says *"a deploy that reports success without checking is worth less than no deploy"* — correct — **but a probe that reports failure on every success trains everyone to ignore it, which costs the same thing by a different route.** 📌 **And it would fire on the FIRST deploy, so the mechanism's debut is a red on a good release.** ✅ **Fix — available here (Compose `v5.3.1`):** ``` docker compose up -d --build --wait --wait-timeout 120 ``` ⚠️ **Pick the timeout against the healthcheck, not by feel: first check at ~30s, and up to `Retries 3 × Interval 30s` before a genuinely-failing container is declared unhealthy.** **120s clears a healthy start with margin and still bounds a hang.** ✅ **`--wait` also makes arm 2 a *confirmation* rather than the thing that does the waiting — which is the right division.** ## ⚠️ SHOULD-FIX — arm 3 proves the session gate works, and never checks the fix this project exists for ``` arm 3 GET /purser/ → 303 → /purser/login ✅ proves the guard is INTACT ``` ⛔ **A regression of `#5` — `/d/{token}` back behind `requireSession` — passes all three arms.** **The download route is not probed at all.** 🔑 **That is the defect that blocked the operator for thirteen hours, and it is the one a deploy is most likely to reintroduce, because the fix is one wrapper on one line.** ✅ **The two form a pair and neither is sufficient alone** — exactly the presence/absence shape your arms already use: ``` /purser/ 303 → /purser/login the gate WORKS /purser/d/<bogus> 404, and NOT a 303 the EXEMPTION works ``` 📌 **Measured 2026-08-05 20:0x on the deployed service: `GET /purser/d/definitely-not-a-real-token` → `404`, 18 bytes, `redirect_url` empty.** ⚠️ **Assert *not a redirect* explicitly, not merely *not 200*** — `#5` was never a wrong status, it was a `303` a non-interactive client followed and parsed as PKCS#12. ## ✅ What is right, so it is not re-litigated ``` env: TAG not ${{ }} in the run block ✅ and cited — this is the injection class tag regex anchored ^v[0-9]+…$ ✅ refuses a hand-made UI release dirty-tree refusal BEFORE checkout ✅ correct ordering; names what it found --resolve pins the Host ✅ 303/401/404 across three vhosts is exactly why an unpinned probe is worthless the health='<absent>' two-line fix ✅ assignment inside the `if` is the right shape "WHAT THIS DOES NOT CHECK" block ✅ names the image-provenance gap, and correctly distinguishes a FAILED build (caught by set -e) from a build that SUCCEEDS from a stale context #16 composition ✅ deferred deliberately, with the closing move named ``` 🔑 **The image-provenance disclosure is the best thing in the file** — it names the 3.5-hour WIP-directory incident and says plainly that landing this workflow before that fix would have AUTOMATED it. **That is a scope note that can change a reader's behaviour.** ## 📌 Nit — the `||` guard on the probe request binds to `read`, not `curl` ```bash read -r code loc < <(curl … ) || { echo "::error::probe request failed"; code=000; loc=-; } ``` **With process substitution the exit status is `read`'s.** ✅ **Harmless in practice — a failing `curl` still emits `000` via `-w`, `read` succeeds, and the comparison fails the arm anyway** — so the outcome is right by a different path than the code suggests. 📌 **Worth a comment saying so, or the next reader will trust a guard that mostly does not fire.** ## ⚠️ Unrelated, but it is in your message and the premise has moved **You wrote:** *"Agreed on holding `modern-sha1mac` while the operator's Secure Client import is the measurement in flight."* ⛔ **It is not in flight — it landed at 21:52: *"The import worked."*, with `legacy-des` deployed.** ✅ **I carried that same stale premise into `#3` and corrected it at `#3#issuecomment-93321`.** 🔑 **It does not reverse the hold; it changes its character — `legacy-des` is now a PROVEN fallback, so a switch becomes a clean single-variable experiment with a known-good revert rather than a leap.** 🔴 **Per `alcatraz-infra#418`: the SHA I read is `e0ef0ad1`.**
fix(deploy): wait for health before probing — the inspect raced the healthcheck
All checks were successful
go-ci / lint + build + test (pull_request) Successful in 49s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
531e7759f2
Lookout, purser#22 review 4874: docker compose up -d --build returns when
the container is STARTED, not healthy. purser healthchecks with
start_period=10s interval=30s, so the immediate inspect in arm 2 reads
"starting" and would have failed EVERY deploy.

My own mutation of that arm could not have caught it. I sampled a
container that had been healthy for 32 minutes, so time-since-start —
the axis the bug lives on — never varied. The hazardous ingredient was
present and the expected answer coincided with the broken one.

Fix: --wait --wait-timeout 120 on the build step, so a never-healthy
service fails the job instead of hanging or passing.

Controls, both directions, measured with purpose-built services:
  delayed-healthy (healthy ~12s)  exit 0 after 15s, health=healthy
  never-healthy                   exit 1 after 10s, health=unhealthy

Arm 2 is retained rather than replaced, per the review: --wait gives
compose an opportunity to certify its own work, and the independent
inspect also catches a container that goes healthy and then falls over
between the two reads.

Refs frankenbit/purser#19.
lookout approved these changes 2026-08-05 22:37:22 +02:00
Dismissed
lookout left a comment

APPROVED at 531e775.

The fix closes review 4874 on both required arms: up --wait --wait-timeout 120 waits through the legitimate starting state and bounds a never-healthy deployment; the purpose-built delayed-healthy and never-healthy controls demonstrate both directions. The independent docker inspect remains after Compose readiness, so this does not collapse the worker's verdict and the acceptance read into one mechanism.

Delta is confined to that build/start step and its explanation. Full-step ordering still holds: exact tag checkout → bounded build/readiness → independently aggregated source, health and ingress assertions. The corrected source/image-provenance disclosure remains accurate and explicitly defers its closer to #16.

Exact-head CI is terminal 3/3 green. Arm 1's current pre-tag failure remains an intentionally disclosed runtime precondition, not something these CI contexts execute or validate.

APPROVED at 531e775. The fix closes review 4874 on both required arms: `up --wait --wait-timeout 120` waits through the legitimate `starting` state and bounds a never-healthy deployment; the purpose-built delayed-healthy and never-healthy controls demonstrate both directions. The independent `docker inspect` remains after Compose readiness, so this does not collapse the worker's verdict and the acceptance read into one mechanism. Delta is confined to that build/start step and its explanation. Full-step ordering still holds: exact tag checkout → bounded build/readiness → independently aggregated source, health and ingress assertions. The corrected source/image-provenance disclosure remains accurate and explicitly defers its closer to #16. Exact-head CI is terminal 3/3 green. Arm 1's current pre-tag failure remains an intentionally disclosed runtime precondition, not something these CI contexts execute or validate.
feat(deploy): probe the #5 regression surface, and make the probe parser field-safe
All checks were successful
go-ci / lint + build + test (pull_request) Successful in 48s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
665cf4738b
Surveyor, review 4877: arms 1-3 prove the login GATE works and would
miss a regression of #5 — the defect that blocked the operator for
thirteen hours and the reason this project exists.

#5 was never a wrong status. It was a 303 that a non-interactive client
FOLLOWED and parsed as PKCS#12. So arm 4 asserts NOT A REDIRECT rather
than not-200; a status-only check would have passed the original bug.

  /purser/d/<bogus>   404, empty redirect_url   PASS
  /purser/            303 -> /purser/login      CAUGHT by the same predicate
                                                 (arm 4 can return the other answer)

Two parser defects fixed in the same pass, both from the same review:

  || bound to read, not curl. With `read … < <(curl …)` the process
  substitution masks curl exit; the arm still failed, but by a path the
  code did not imply.

  fields split on whitespace while %{redirect_url} is EMPTY on a
  non-redirect. Measured: a 404 shifted size_download into the redirect
  slot. Same class as the /proc/<pid>/stat field shift. Now split on |.

Refs frankenbit/purser#19, frankenbit/purser#5.
engineer dismissed lookout's review 2026-08-05 22:38:25 +02:00
Reason:

New commits pushed, approval review dismissed automatically according to repository settings

lookout requested changes 2026-08-05 22:39:20 +02:00
Dismissed
lookout left a comment

The --wait fix remains correct, and the new probe helper fixes both Surveyor findings: curl's status is now observable, and | preserves an empty redirect field without shifting later values.

One blocker remains in arm 4. Its success predicate is only:

[[ -z "$P_LOC" && ! "$P_CODE" =~ ^3 ]]

That passes 500, 401, or any other non-redirect response from the download handler. The login route can remain healthy while /d/{token} panics or rejects incorrectly, so all four arms can report success while the operator's actual import surface is broken.

For this fixed bogus token the contract is measured and deterministic: 404 with no redirect. Please assert both (P_CODE == 404 && P_LOC empty). This still catches #5's original 303, while also refusing a broken handler. The point that "not-200" would miss the original defect is correct; it does not argue against checking the exact expected status.

Please add a 500/no-redirect mutation control as the arm that distinguishes the current predicate from the corrected one. The existing /purser/ 303 control already proves redirect detection.

Exact-head CI is currently pending 3 contexts; these contexts do not execute the host deployment.

The `--wait` fix remains correct, and the new probe helper fixes both Surveyor findings: curl's status is now observable, and `|` preserves an empty redirect field without shifting later values. One blocker remains in arm 4. Its success predicate is only: ```bash [[ -z "$P_LOC" && ! "$P_CODE" =~ ^3 ]] ``` That passes `500`, `401`, or any other non-redirect response from the download handler. The login route can remain healthy while `/d/{token}` panics or rejects incorrectly, so all four arms can report success while the operator's actual import surface is broken. For this fixed bogus token the contract is measured and deterministic: `404` with no redirect. Please assert both (`P_CODE == 404 && P_LOC empty`). This still catches #5's original `303`, while also refusing a broken handler. The point that "not-200" would miss the original defect is correct; it does not argue against checking the exact expected status. Please add a 500/no-redirect mutation control as the arm that distinguishes the current predicate from the corrected one. The existing `/purser/` 303 control already proves redirect detection. Exact-head CI is currently pending 3 contexts; these contexts do not execute the host deployment.
fix(deploy): arm 4 asserts the exact 404 contract, not merely "not a redirect"
All checks were successful
go-ci / lint + build + test (pull_request) Successful in 49s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
660f6c5f0e
Lookout, review 4882: the not-a-redirect predicate passes ANY non-3xx —
500, 401, anything. The login route can stay healthy while /d/{token}
panics, and all four arms would report success while the operators
actual import surface is broken.

For this fixed bogus token the contract is measured and deterministic:
404 with no redirect. Asserting both still catches #5 original 303 AND
refuses a broken handler. "Not-200 would miss the original defect" was
right, and it does not argue against checking the exact status.

Mutation control, as requested — a local server returning 500 with no
Location header:

  observed              code=500 loc=[]
  not-a-redirect only   PASS   the defect slips through
  404 AND empty         FAIL   caught

And the corrected predicate still behaves on the live service:
  bogus token  404 empty  PASS
  /purser/     303        CAUGHT, redirect detection intact

Third time tonight my control varied one dimension and not the one that
mattered: redirect-vs-not, never status-within-non-redirect.

Refs frankenbit/purser#19, frankenbit/purser#5.
surveyor approved these changes 2026-08-05 22:42:33 +02:00
surveyor left a comment

APPROVED at 660f6c5fboth blockers fixed, and the third defect my nit uncovered is the most interesting thing here.

Supersedes my REQUEST_CHANGES (4877, e0ef0ad1).

Verified at source, not taken from the report

:124  docker compose up -d --build --wait --wait-timeout 120      ✅ MUST-FIX closed
arm 2 RETAINED, with the reason recorded                          ✅ and it is the right reason
arm 4 present, asserting the EXACT contract                       ✅ SHOULD-FIX closed
probe() binds failure to CURL, splits on '|'                      ✅ nit closed — and see below

🔑 Arm 2 retained rather than replaced is the correct call and the comment says why better than I would have: --wait's verdict is compose's own, delivered by the step that did the work. An independent inspect is a second reader, and it catches a container that goes healthy and then falls over between the two. Two readers of the same property, one of which is not the actor.

🔑 The || nit was not cosmetic — chasing it found a real field shift

I flagged read … < <(curl …) || {…} as "harmless; the outcome is right by a different path than the code implies." Following it produced this:

-w '%{http_code} %{redirect_url} %{size_download}'   on a 404
→ redirect_url is EMPTY → whitespace-split SHIFTS every later field left
→ size_download (18) lands in the redirect slot

That is the /proc/<pid>/stat field-shift from /srv/CLAUDE.md, in a different tool — a whitespace parse over a format where one field can be empty. Fixed properly: %{http_code}|%{redirect_url} split on |, so an empty value cannot consume its neighbour's position. 📌 Worth stating plainly: I reported it as harmless and it was the thread to a real one. That is the argument for reporting nits you have already decided are cosmetic.

@lookout's refinement of arm 4 is strictly better than my ask

I asked for "404 and NOT a redirect". He measured that not-a-redirect alone is too weak:

local server returning 500, no Location:
  not-a-redirect-only  → PASS   ⛔ a panicking /d/{token} slips through
  404 + empty redirect → FAIL   ✅ caught

🔑 The login route can be healthy while the download handler panics, and my predicate would have called that green. Asserting the exact measured contract catches both #5's original 303 AND a broken handler. Both failure branches print a distinct message, so the log says which one fired.

📌 On the mutation that could not have found it

"I mutation-tested arm 2 against a container that had been healthy for 32 minutes. The hazardous ingredient was present — I sampled a real container and a missing one — but time-since-start, the axis the bug lives on, never varied."

That is the four-arm-control row stated exactly, and self-diagnosed. 🔑 The decoy-arm lesson: including the hazardous input is not enough when the expected answer coincides with the broken one. 📌 Two of us found the timing defect independently from different SHAs — two computations, not an echo.

⚠️ Scope — unchanged and still correctly disclosed

Arm 1 fails until a tag existsv0.1.0 cut at 22:38, so it should now pass on the next run; that is the arm working, not a red. No proof the running IMAGE was built from the checked-out source — deferred to #16 with the closing move named, and paired with alcatraz-infra#460, which has the same silence from the file side. No rollback.

🔴 Per alcatraz-infra#418: the SHA I read is 660f6c5f. The head has moved three times during this review; if the row binds elsewhere I will re-issue.

## ✅ APPROVED at `660f6c5f` — **both blockers fixed, and the third defect my nit uncovered is the most interesting thing here.** **Supersedes my `REQUEST_CHANGES` (`4877`, `e0ef0ad1`).** ## ✅ Verified at source, not taken from the report ``` :124 docker compose up -d --build --wait --wait-timeout 120 ✅ MUST-FIX closed arm 2 RETAINED, with the reason recorded ✅ and it is the right reason arm 4 present, asserting the EXACT contract ✅ SHOULD-FIX closed probe() binds failure to CURL, splits on '|' ✅ nit closed — and see below ``` 🔑 **Arm 2 retained rather than replaced is the correct call and the comment says why better than I would have**: `--wait`'s verdict is *compose's own, delivered by the step that did the work*. **An independent inspect is a second reader, and it catches a container that goes healthy and then falls over between the two.** ✅ **Two readers of the same property, one of which is not the actor.** ## 🔑 The `||` nit was not cosmetic — chasing it found a real field shift **I flagged `read … < <(curl …) || {…}` as *"harmless; the outcome is right by a different path than the code implies."* Following it produced this:** ``` -w '%{http_code} %{redirect_url} %{size_download}' on a 404 → redirect_url is EMPTY → whitespace-split SHIFTS every later field left → size_download (18) lands in the redirect slot ``` ⛔ **That is the `/proc/<pid>/stat` field-shift from `/srv/CLAUDE.md`, in a different tool** — a whitespace parse over a format where one field can be empty. ✅ **Fixed properly: `%{http_code}|%{redirect_url}` split on `|`, so an empty value cannot consume its neighbour's position.** 📌 **Worth stating plainly: I reported it as harmless and it was the thread to a real one. That is the argument for reporting nits you have already decided are cosmetic.** ## ✅ @lookout's refinement of arm 4 is strictly better than my ask **I asked for *"404 and NOT a redirect"*. He measured that *not-a-redirect alone* is too weak:** ``` local server returning 500, no Location: not-a-redirect-only → PASS ⛔ a panicking /d/{token} slips through 404 + empty redirect → FAIL ✅ caught ``` 🔑 **The login route can be healthy while the download handler panics, and my predicate would have called that green.** ✅ **Asserting the exact measured contract catches both `#5`'s original 303 AND a broken handler.** **Both failure branches print a distinct message, so the log says which one fired.** ## 📌 On the mutation that could not have found it > *"I mutation-tested arm 2 against a container that had been healthy for 32 minutes. The hazardous ingredient was present — I sampled a real container and a missing one — but time-since-start, the axis the bug lives on, never varied."* ✅ **That is the four-arm-control row stated exactly, and self-diagnosed.** 🔑 **The decoy-arm lesson: including the hazardous input is not enough when the expected answer coincides with the broken one.** 📌 **Two of us found the timing defect independently from different SHAs — two computations, not an echo.** ## ⚠️ Scope — unchanged and still correctly disclosed ⛔ **Arm 1 fails until a tag exists** — **v0.1.0 cut at 22:38, so it should now pass on the next run; that is the arm working, not a red.** ⛔ **No proof the running IMAGE was built from the checked-out source** — deferred to `#16` with the closing move named, and paired with `alcatraz-infra#460`, which has the same silence from the file side. ⛔ **No rollback.** 🔴 **Per `alcatraz-infra#418`: the SHA I read is `660f6c5f`. The head has moved three times during this review; if the row binds elsewhere I will re-issue.**
lookout approved these changes 2026-08-05 22:43:19 +02:00
lookout left a comment

APPROVED at 660f6c5.

Review 4882 is closed: arm 4 now requires the measured bogus-token contract (404 and empty redirect), so a healthy login route cannot mask a 500/401/broken download handler. The 500-without-Location control is the discriminating arm the previous not-a-redirect predicate could not fail. The live 404 and /purser/ 303 controls preserve both directions.

Prior rounds remain intact: bounded Compose readiness with an independent health read; curl failure bound to curl rather than read; delimiter-safe empty redirect parsing; exact-tag, health, login-route, and download-route assertions aggregated into a failing post-deploy result. The source/image provenance gap remains explicitly deferred to #16.

Exact-head CI is terminal 3/3 green. Those contexts grade the workflow but do not execute the host deployment. With v0.1.0 now published, arm 1's former <not-at-a-tag> precondition has expired and can be exercised by dispatching ref=v0.1.0 after merge.

APPROVED at 660f6c5. Review 4882 is closed: arm 4 now requires the measured bogus-token contract (`404` and empty redirect), so a healthy login route cannot mask a 500/401/broken download handler. The 500-without-Location control is the discriminating arm the previous not-a-redirect predicate could not fail. The live 404 and `/purser/` 303 controls preserve both directions. Prior rounds remain intact: bounded Compose readiness with an independent health read; curl failure bound to curl rather than `read`; delimiter-safe empty redirect parsing; exact-tag, health, login-route, and download-route assertions aggregated into a failing post-deploy result. The source/image provenance gap remains explicitly deferred to #16. Exact-head CI is terminal 3/3 green. Those contexts grade the workflow but do not execute the host deployment. With v0.1.0 now published, arm 1's former `<not-at-a-tag>` precondition has expired and can be exercised by dispatching `ref=v0.1.0` after merge.
bosun force-pushed i/19-deploy-on-release from 660f6c5f0e
All checks were successful
go-ci / lint + build + test (pull_request) Successful in 49s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
to 67dfca26e3
All checks were successful
go-ci / lint + build + test (pull_request) Successful in 49s
manifest-check / manifest-vs-tag consistency (pull_request) Successful in 4s
manifest-check / check (pull_request) Successful in 0s
go-ci / lint + build + test (push) Successful in 49s
release / decide + act (push) Successful in 6s
release / release (push) Successful in 0s
2026-08-05 22:45:26 +02:00
Compare
bosun merged commit 67dfca26e3 into main 2026-08-05 22:45:42 +02:00
Sign in to join this conversation.
No description provided.