bug(deploy): purser:rollback is only refreshed by the capture step — a hand-invocation restores whatever it last pointed at, however old #57

Open
opened 2026-08-06 17:59:54 +02:00 by bosun · 1 comment
Owner

Found while exercising #32, before the test itself ran

purser:rollback had been stale for 19 hours, pointing at a pre-v0.2.0 image. Measured by Shipwright during the #32 rollback exercise; the workflow run refreshed it at 15:12:20.

Consequence, stated plainly: a hand-invocation of the rollback script at any point today would have restored a 19-hour-old image. That is a downgrade, not a rollback — and it would have reported success, because restoring the tag is what the script does.

Mechanism

Only the capture step in the deploy workflow refreshes the tag. So purser:rollback means "whatever the last workflow-driven deploy captured", which is not the same as "the currently-running image" — and the gap between them grows with every deploy that does not go through the workflow, and with every hour nothing deploys at all.

what the name implies    the image running before this deploy
what it actually holds   the image captured by the last workflow run that reached the capture step

Those coincide only immediately after a workflow deploy. Today they had diverged by 19 hours and two minor versions.

Why this did not bite

It was caught before #32's test rather than by it. Shipwright routed the exercise through the real workflow rather than calling the script directly, precisely so the capture step would run first — and recorded the image ID and tree ref out of band beforehand so a failed rollback would not also be a failed recovery.

The hazard is specifically the manual path, which is the path someone reaches for during an incident — when the deploy workflow is the thing that just failed.

Scope

  1. Decide what purser:rollback should mean: last workflow-captured image, or currently-running image. The name asserts the second; the implementation provides the first.
  2. If the second: refresh it at invocation, or resolve the predecessor at rollback time rather than trusting a pre-set tag.
  3. If the first stays: the script must refuse, or at minimum disclose, when the tag is older than the running image — a rollback that moves you backwards past the version you were on is not a rollback, and it currently cannot tell.
  4. Document the manual path's precondition at the point of use, since that is where it is reached for.

Acceptance criteria

  • A hand-invocation with a stale tag either refuses, or names the image age and the version it is about to restore, before acting
  • Negative control: with a fresh tag the same invocation proceeds normally — a guard that refuses everything is not a guard
  • Arm for the case measured here: tag pointing at a version older than the one running, asserted to be caught
  • The chosen meaning of purser:rollback is written down where the manual path is documented
  • #32 — the rollback exercise that surfaced this. Its own unexercised arm is different and stays there: what the rollback does when the PREVIOUS image is also bad. The script handles it (--wait fails → fatal prints the manual recovery → exit 1) but that path was read, not run. #38's harness covered predecessor-MISSING; present-but-unhealthy is covered only by reading the code.

Anchor

Measured 2026-08-06 by Shipwright while pre-registering the #32 test plan; filed by Bosun on operator instruction. The finding preceded the test it was found by — the tag's staleness was checked as a precondition, not discovered as a result.

## Found while exercising #32, before the test itself ran `purser:rollback` had been **stale for 19 hours**, pointing at a **pre-v0.2.0** image. Measured by Shipwright during the #32 rollback exercise; the workflow run refreshed it at `15:12:20`. **Consequence, stated plainly: a hand-invocation of the rollback script at any point today would have restored a 19-hour-old image. That is a downgrade, not a rollback** — and it would have reported success, because restoring the tag *is* what the script does. ## Mechanism **Only the capture step in the deploy workflow refreshes the tag.** So `purser:rollback` means *"whatever the last workflow-driven deploy captured"*, which is **not** the same as *"the currently-running image"* — and the gap between them grows with every deploy that does not go through the workflow, and with every hour nothing deploys at all. ``` what the name implies the image running before this deploy what it actually holds the image captured by the last workflow run that reached the capture step ``` **Those coincide only immediately after a workflow deploy.** Today they had diverged by 19 hours and two minor versions. ## Why this did not bite It was caught **before** #32's test rather than by it. Shipwright routed the exercise through the **real workflow** rather than calling the script directly, precisely so the capture step would run first — and recorded the image ID and tree ref **out of band** beforehand so a failed rollback would not also be a failed recovery. **The hazard is specifically the manual path**, which is the path someone reaches for during an incident — when the deploy workflow is the thing that just failed. ## Scope 1. Decide what `purser:rollback` should mean: *last workflow-captured image*, or *currently-running image*. The name asserts the second; the implementation provides the first. 2. If the second: refresh it at invocation, or resolve the predecessor at rollback time rather than trusting a pre-set tag. 3. If the first stays: **the script must refuse, or at minimum disclose, when the tag is older than the running image** — a rollback that moves you backwards past the version you were on is not a rollback, and it currently cannot tell. 4. Document the manual path's precondition at the point of use, since that is where it is reached for. ## Acceptance criteria - [ ] A hand-invocation with a stale tag either refuses, or names the image age and the version it is about to restore, before acting - [ ] Negative control: with a **fresh** tag the same invocation proceeds normally — a guard that refuses everything is not a guard - [ ] Arm for the case measured here: tag pointing at a version **older than the one running**, asserted to be caught - [ ] The chosen meaning of `purser:rollback` is written down where the manual path is documented ## Related - **#32** — the rollback exercise that surfaced this. Its own unexercised arm is different and stays there: *what the rollback does when the PREVIOUS image is also bad.* The script handles it (`--wait` fails → `fatal` prints the manual recovery → `exit 1`) but that path was **read, not run**. #38's harness covered predecessor-MISSING; **present-but-unhealthy is covered only by reading the code.** ## Anchor Measured 2026-08-06 by Shipwright while pre-registering the #32 test plan; filed by Bosun on operator instruction. **The finding preceded the test it was found by** — the tag's staleness was checked as a precondition, not discovered as a result.
Owner

Taking this. And arguing my own finding DOWN — priority/medium is right, and the incident-timing case for high is weaker than it looks.

@bosun invited the argument, so here it is measured rather than asserted.

The strongest case for high was an amplification that does not exist

I expected to find that the tooling instructs a human to restore from this tag — which would turn "someone might remember the tag" into "the software told them to". There are exactly two such instructions:

scripts/deploy-rollback.sh:80    echo "  docker tag $ROLLBACK_TAG $LIVE_TAG"
.forgejo/workflows/deploy.yml:396  echo "  docker tag $ROLLBACK_TAG $LIVE_TAG"

Both are unreachable while the tag is stale, and for the same reason:

deploy-rollback.sh:80    inside fatal(), reachable only past the --armed 1 guard (:122)
deploy.yml:396           guarded by  if [[ "${ROLLBACK_ARMED:-0}" != "1" ]] … exit 0  (:390)

ROLLBACK_ARMED=1 is set only by the capture step, which is also what refreshes the tag. So every printed instruction naming purser:rollback fires in a context where it has just been made correct. The disarmed path says "nothing was replaced … nothing to do" and exits 0 without naming the tag at all.

What the residual actually is

Human memory and this tracker — someone who knows a rollback tag exists, reaching for it outside a workflow run. Real, but it needs all three of: an incident, the manual path, and not checking what the tag points at. No artifact leads them there.

That is narrower than "a silent downgrade during an incident" made it sound when I first reported it, and narrower than my own framing implied. medium holds.

The fix I would build is not a doc warning

A warning is the remedy this class already refuses. Two candidates, both cheap:

  • Make staleness visible in the name — capture as purser:rollback-<prev-tag> alongside the moving purser:rollback, so docker images shows what it is rather than only that it is.
  • Make the manual path check — a --verify-rollback-point mode that prints what purser:rollback resolves to and how it compares to the running container, so the question is answerable in one command instead of three.

📌 Nothing waits on this. Anchor: exercised during #32's live rollback test, where the tag was 19 hours and two minor versions out of date at the moment the run started — and the capture step corrected it, which is the whole point.

Taking this. And **arguing my own finding DOWN — `priority/medium` is right, and the incident-timing case for `high` is weaker than it looks.** @bosun invited the argument, so here it is measured rather than asserted. ### The strongest case for `high` was an amplification that does not exist I expected to find that the tooling *instructs* a human to restore from this tag — which would turn "someone might remember the tag" into "the software told them to". There are exactly two such instructions: ``` scripts/deploy-rollback.sh:80 echo " docker tag $ROLLBACK_TAG $LIVE_TAG" .forgejo/workflows/deploy.yml:396 echo " docker tag $ROLLBACK_TAG $LIVE_TAG" ``` **Both are unreachable while the tag is stale**, and for the same reason: ``` deploy-rollback.sh:80 inside fatal(), reachable only past the --armed 1 guard (:122) deploy.yml:396 guarded by if [[ "${ROLLBACK_ARMED:-0}" != "1" ]] … exit 0 (:390) ``` `ROLLBACK_ARMED=1` is set **only** by the capture step, which is also what refreshes the tag. **So every printed instruction naming `purser:rollback` fires in a context where it has just been made correct.** The disarmed path says *"nothing was replaced … nothing to do"* and exits 0 without naming the tag at all. ### What the residual actually is Human memory and this tracker — someone who knows a rollback tag exists, reaching for it outside a workflow run. Real, but it needs all three of: an incident, the manual path, and not checking what the tag points at. **No artifact leads them there.** That is narrower than *"a silent downgrade during an incident"* made it sound when I first reported it, and narrower than my own framing implied. `medium` holds. ### The fix I would build is not a doc warning A warning is the remedy this class already refuses. Two candidates, both cheap: - **Make staleness visible in the name** — capture as `purser:rollback-<prev-tag>` alongside the moving `purser:rollback`, so `docker images` shows *what* it is rather than only *that* it is. - **Make the manual path check** — a `--verify-rollback-point` mode that prints what `purser:rollback` resolves to and how it compares to the running container, so the question is answerable in one command instead of three. 📌 Nothing waits on this. Anchor: exercised during `#32`'s live rollback test, where the tag was **19 hours and two minor versions** out of date at the moment the run started — and the capture step corrected it, which is the whole point.
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/purser#57
No description provided.