A release history page that never shows a soaking release as good A static export in Bun and TypeScript
Five real deploys in, five pages out: only a release whose soak window closed with its budget intact gets the good marker, and a ledger that disagrees with its report stops the build.
Lab download. Starter is the tree you edit. Worked answer is a separate file; open it after the starter passes. Bun 1.3 or newer. package.json has no dependencies to install. All lab requirements.
A deploy tool can be careful about when it calls a release good. A dashboard built on top of it can still undo that care with one lazy line: "not rolled back, so show a green tick". This lesson builds a release history page where the green marker means one thing only, and where the page refuses to be built if its inputs contradict each other.
The page is a Bun static export written in TypeScript. There is no server and no client-side JavaScript.
What is real in this lesson: the TypeScript, the build, the page HTML and the check output quoted below, all produced by running the lab. What is simulated: the deployer that produced the inputs. It is a decision kernel that reads a fixture plan of timestamped events and decides what to do. It starts no live processes, reads no clock and talks to no cluster. Time moves only when an event's ts field says so. Nothing in this lesson deploys anything.
1. Concept
The inputs. The page is driven by the text output of a small deployer that puts a new release on probation. Here is that deployer in plain words:
- A baseline release is
serving. It counts as confirmed by definition. - A candidate is
promoted. It takes over traffic, and its soak window starts. - During the window, each time the candidate's process
exited, the supervisor restarts it and the deployer spends one unit of a restart budget. - The policy constants are a 30 000 ms window and a budget of 2. Two restarts are allowed, and the third exit inside the window triggers a rollback.
- If the budget is spent inside the window, the deployer rolls back to the last confirmed release and records the reason.
- If the window closes with the budget intact, the candidate becomes
confirmed. - Exits after the window closes are logged as
restarted_post_soak. They never roll back this deploy.
It writes three text files per deploy:
- The plan: JSONL events, each with
ts(milliseconds) andevent. Release events carryversion. - The apply ledger: JSONL lines naming what the deployer decided (
restarting,confirmed,rolled_back,restarted_post_soak). - The report: seven
key: valuelines (soak_ms,max_restarts,current,last_good,candidate,candidate.restarts,deploy).
Why readiness at one instant is a weak test. Kubernetes has a field for exactly this gap. .spec.minReadySeconds "specifies the minimum number of seconds for which a newly created Pod should be ready without any of its containers crashing, for it to be considered available. This defaults to 0 (the Pod will be considered available as soon as it is ready)." The default is no probation. A separate knob, the progress deadline, surfaces a stalled rollout as a Progressing condition with reason ProgressDeadlineExceeded. Kubernetes takes no action on a stalled Deployment other than to report that condition. So someone or something above it still has to decide what the stall means and display it.
The same applies to any page that says "good". A release that has been serving for three seconds and has already crashed once is not good yet. Showing it as good is a false claim, even if the release later turns out fine. The SRE Workbook's chapter on canarying makes the same point about evaluation. A canary needs enough size and duration to be representative, and ending it after a handful of requests gives no useful signal.
Three statuses, one marker. Every release on the page is in exactly one state:
| Status | Meaning | Good marker |
|---|---|---|
confirmed | The baseline, or a candidate whose window closed with the budget intact | yes |
soaking | Serving, but still on probation | no |
rolled back (budget) | Spent its restart budget inside the window; traffic went back to the last confirmed release | no |
All rendering goes through a single function whose only input is the derived status:
export const isGood = (r: Release): boolean => r.status === "confirmed";
There is no "not rolled back, therefore good" shortcut. The rule is written in the positive, so a status added later is not good until someone deliberately makes it so.
Derive, then cross-check. The page does not copy statuses out of the report. It walks the ledger in order, tracking the last confirmed release itself, and then compares the report to what it derived. The report is only a summary, and a summary can be stale or wrong. When the two disagree, the build stops. The page never silently picks a side. The disagreements the page checks for are:
last_goodnames a release that is still soaking.deploy: confirmedwhile the ledger has noconfirmedline.currentnames a rolled-back release.- A rollback's
tois not the last confirmed release. - A ledger line has no
version.
Failing at build time is the right place for this. A wrong history page is worst when someone is looking at it during an incident. A build that exits non-zero gives a pipeline that stops on failure a reason not to publish.
Why there is no Bun backend. The page reads three files per deploy at build time. Nothing on it changes after the build. A server would serve the same bytes on every request, so any static host does the job. Checking consistency per request would also move the failure to the worst moment, when the page is open. The tool is Bun, but only as a build script and a TypeScript runner. The file work uses Bun.file(...).text() and Bun.write. For mkdir and readdir, the Bun file I/O documentation points to its node:fs implementation, which is what build.ts uses.
A hand-written parser, on purpose. The deployer is a Rust program. This lesson does not generate a TypeScript client from a Rust schema. Its output is plain JSONL and key: value text, and the parser is about thirty-five lines. The cost of writing it by hand is that the parser and the deployer can drift apart. The guard is that the parser is strict:
- It demands a numeric
tsand a stringevent. - It demands
versionon every ledger line. - It rejects any ledger
eventit does not know. Plan events other thanservingandpromoted(such asexitedandtick) are checked for shape and then skipped, because the ledger already records what the deployer did about them.
A new deployer event therefore fails the build instead of being skipped.
A display label is not state. Kubernetes documents that a Pod that is failing to start repeatedly may show CrashLoopBackOff in the Status field of some kubectl commands. That is a display field, not the Pod phase. The same split applies here. The row text and the CSS class are a display of a status that was derived and checked, and they should never become the source of truth.
2. Worked example
All output below is from the lab. Fixtures are real output of the deployer's reference build, plus two hand-edited forgeries.
A soaking release
The plan, the ledger and the report of the soaking deploy:
=== plan soaking
{"ts":0,"event":"serving","version":"v1"}
{"ts":1000,"event":"promoted","version":"v2","previous":"v1"}
{"ts":3000,"event":"exited","version":"v2"}
=== apply soaking
{"attempt":1,"event":"restarting","max":2,"ts":3000,"version":"v2"}
=== report soaking
soak_ms: 30000
max_restarts: 2
current: v2
last_good: v1
candidate: v2
candidate.restarts: 1
deploy: soaking
history.ts builds the release list from the plan (every serving and promoted line, in order). The baseline v1 starts as confirmed. The candidate v2 starts as soaking. Then it walks the ledger:
restartingattempt 1 of 2 forv2. The page's own count is now 1, which matches.- The ledger ends.
v2was never confirmed or rolled back, so it is stillsoaking, and the last confirmed release is stillv1.
The report check then agrees on each field:
last_good: v1matches.deploy: soakingmatches the ledger.candidate.restarts: 1matches the count.current: v2is right, because a soaking candidate is serving.
The page the reference build produces:
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>release history: soaking</title>
<link rel="stylesheet" href="history.css"></head>
<body><main data-current="v2" data-last-good="v1">
<h1>Release history</h1>
<p class="banner warn" role="status" aria-live="polite" data-deploy="soaking">v2 is serving but still on probation; the last confirmed release is v1.</p>
<table><thead><tr><th>Release</th><th>Status</th></tr></thead><tbody>
<tr class="good confirmed" data-version="v1" data-status="confirmed" data-good="true"><td>v1</td><td>confirmed (baseline)</td></tr>
<tr class="soaking" data-version="v2" data-status="soaking" data-good="false"><td>v2 (serving now)</td><td>soaking: on probation, 1 of 2 restarts used; not yet good</td></tr>
</tbody></table>
<p data-field="policy">Soak window 30000 ms, restart budget 2. Last confirmed: v1.</p>
</main></body></html>
Notice three things:
- The only row with the
goodclass anddata-good="true"isv1. Thev2row says "soaking" and "not yet good" in words, so the meaning does not depend on color. - The text "not yet good" and the attribute
data-good="false"both contain the word "good". A checker that greps for the word would misfire. The marker is thegoodclass token, ordata-good="true"on the row. - The page is plain HTML plus a stylesheet, with no script tag.
A rollback, shown as such
The storm deploy exits three times inside the window. In the deployer's own output this fixture is generated under the name flap. Its page:
<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>release history: storm</title>
<link rel="stylesheet" href="history.css"></head>
<body><main data-current="v1" data-last-good="v1">
<h1>Release history</h1>
<p class="banner error" role="alert" aria-live="assertive" data-deploy="rolled_back">v2 was rolled back for spending its restart budget; v1 is serving.</p>
<table><thead><tr><th>Release</th><th>Status</th></tr></thead><tbody>
<tr class="good confirmed" data-version="v1" data-status="confirmed" data-good="true"><td>v1 (serving now)</td><td>confirmed (baseline)</td></tr>
<tr class="rolled_back" data-version="v2" data-status="rolled_back" data-good="false"><td>v2</td><td>rolled back (budget): 3 exits inside the soak window, budget 2 restarts; traffic went back to v1</td></tr>
</tbody></table>
<p data-field="policy">Soak window 30000 ms, restart budget 2. Last confirmed: v1.</p>
</main></body></html>
v1 is marked "(serving now)" because the report's current is v1, the release traffic went back to.
A chain, where the last confirmed release matters
In the chain deploy, v2 is rolled back and v3 is promoted. The plan's promoted line for v3 says previous=v2, which is stale: v2 was just rolled back.
=== apply chain
{"attempt":1,"event":"restarting","max":2,"ts":2000,"version":"v2"}
{"attempt":2,"event":"restarting","max":2,"ts":3000,"version":"v2"}
{"event":"rolled_back","exits_in_window":3,"from":"v2","max":2,"reason":"restart_budget_exceeded","to":"v1","ts":4000,"version":"v2","window_ms":30000}
{"attempt":1,"event":"restarting","max":2,"ts":52000,"version":"v3"}
{"attempt":2,"event":"restarting","max":2,"ts":53000,"version":"v3"}
{"event":"rolled_back","exits_in_window":3,"from":"v3","max":2,"reason":"restart_budget_exceeded","to":"v1","ts":54000,"version":"v3","window_ms":30000}
The walk, with lastGood starting at v1:
v2restarts twice. The attempts are 1 and 2 of 2.v2is rolled back tov1. That equalslastGood, andv2has spent exactly the budget.lastGoodstaysv1, because a rolled-back release never becomes the target.v3does the same, and its rollback also targetsv1.- The report (
candidate: v3,deploy: rolled_back,current: v1,last_good: v1) agrees with all of it.
The page never reads the plan's previous field. It uses the plan only to learn which releases exist and in what order.
Two forgeries that must fail the build
Each of these two deploys was derived from a real one by hand-editing one thing:
forged_target: the second rollback inchainwas changed to"to":"v2", the staleprevious.forged_good: thesoakingreport was changed to saylast_good: v2.
The reference build stops on both:
build error: forged_good: report: last_good v2, ledger says v1
build error: forged_target: ledger: rolled back v3 to v2, last confirmed is v1
build failed: 2 deploy(s) whose ledger and report disagree
Both forgeries are internally plausible. The report is well formed, and the ledger is well formed JSONL. They are only wrong against each other, and against the rule that the rollback target is the last confirmed release. That is why the page has to re-derive instead of trusting either file.
3. Lab
Layout. The lab has two trees and a checker:
starter/andreference/each holdhistory.ts,build.ts,history.cssandpackage.json.check.shwrites the fixtures itself: the five real deploys (storm,survivor,soaking,slowburn,chain) and the two forgeries (forged_target,forged_good).- It builds the page for each fixture and prints one
RESULTline per check.
The starter's bugs, as found by the checker:
- Every promoted release starts as
confirmed, so promotion counts as good at once. isGoodreturns true for anything that is not rolled back.- Nothing cross-checks the report against the ledger.
The eight checks:
| Check | What it requires |
|---|---|
static_build | Every honest deploy builds to its own HTML page |
confirmed_is_good | A survivor's releases carry the good marker, with an ok banner |
soaking_never_good | A soaking release is never marked good, and its banner is warn |
rolled_back_names_budget | A rolled-back release is not good and its banner is error |
post_soak_not_counted | A release that restarts after the window is still confirmed |
chain_target_last_good | In the chain, v1 is good and v2 and v3 are rolled back |
forged_ledger_fails_build | The two forgeries make the build exit non-zero |
disagreement_is_build_error | Seven specific disagreements each throw an error |
What to do:
- Run the checker on
starter/and read the three failures. - Fix the starting status so a promoted release is
soakinguntil the ledger saysconfirmed. - Rewrite
isGoodin terms ofconfirmedonly. - Make the ledger walk track the last confirmed release, and add the report cross-checks until the forgeries fail.
To run it yourself, copy check.sh beside the files of one tree and run sh check.sh. It needs Bun and no network.
The gate runs the checker in a clean container with Bun 1.3 and no network. The invocation looks like this, with <workspace> standing for a copy of the lab and LAB_TREE selecting the tree:
docker run --rm --pull=never --network=none -e LAB_TREE=reference -v <workspace>:/lab -w /lab oven/bun:1.3-debian sh check.sh
Real gate output, reference (the docker line with its temporary path is trimmed). The gate collects the checker's stderr after its stdout, so the build errors from check 7 appear at the end of the block:
chain -> dist/chain.html v1=confirmed(good) v2=rolled_back v3=rolled_back
slowburn -> dist/slowburn.html v1=confirmed(good) v2=confirmed(good)
soaking -> dist/soaking.html v1=confirmed(good) v2=soaking
storm -> dist/storm.html v1=confirmed(good) v2=rolled_back
survivor -> dist/survivor.html v1=confirmed(good) v2=confirmed(good)
built 5 pages
RESULT static_build PASS
survivor: v1=confirmed/true v2=confirmed/true banner=ok
RESULT confirmed_is_good PASS
soaking: v2=soaking/false banner=warn
RESULT soaking_never_good PASS
storm: v2=rolled_back/false banner=error
RESULT rolled_back_names_budget PASS
slowburn: v2=confirmed/true
RESULT post_soak_not_counted PASS
chain: v1=true v2=rolled_back/false v3=rolled_back/false
RESULT chain_target_last_good PASS
RESULT forged_ledger_fails_build PASS
report says confirmed while ledger soaking: threw: report: deploy confirmed, ledger says v2 is soaking
report serving v2 after rollback: threw: report: current v2, ledger says v1
report candidate.restarts 1, ledger 2: threw: report: candidate.restarts 1, ledger counts 2
rollback reason not budget: threw: ledger: rollback reason probe_failed is not restart_budget_exceeded
ledger line without version: threw: ledger line 1: restarting has no version
rollback to a release never confirmed: threw: ledger: rolled back v2 to v2, last confirmed is v1
report calls the soaking release last_good: threw: report: last_good v2, ledger says v1
RESULT disagreement_is_build_error PASS
$ bun build.ts fixtures dist
build error: forged_good: report: last_good v2, ledger says v1
build error: forged_target: ledger: rolled back v3 to v2, last confirmed is v1
build failed: 2 deploy(s) whose ledger and report disagree
Real gate output, starter:
chain -> dist/chain.html v1=confirmed(good) v2=rolled_back v3=rolled_back
slowburn -> dist/slowburn.html v1=confirmed(good) v2=confirmed(good)
soaking -> dist/soaking.html v1=confirmed(good) v2=confirmed(good)
storm -> dist/storm.html v1=confirmed(good) v2=rolled_back
survivor -> dist/survivor.html v1=confirmed(good) v2=confirmed(good)
built 5 pages
RESULT static_build PASS
survivor: v1=confirmed/true v2=confirmed/true banner=ok
RESULT confirmed_is_good PASS
soaking: v2=confirmed/true banner=ok
RESULT soaking_never_good FAIL
storm: v2=rolled_back/false banner=error
RESULT rolled_back_names_budget PASS
slowburn: v2=confirmed/true
RESULT post_soak_not_counted PASS
chain: v1=true v2=rolled_back/false v3=rolled_back/false
RESULT chain_target_last_good PASS
forged_good -> dist-rejected/forged_good.html v1=confirmed(good) v2=confirmed(good)
forged_target -> dist-rejected/forged_target.html v1=confirmed(good) v2=rolled_back v3=rolled_back
forged: build exited 0
RESULT forged_ledger_fails_build FAIL
report says confirmed while ledger soaking: did NOT throw
RESULT disagreement_is_build_error FAIL
$ bun build.ts fixtures dist
reference: PASS
starter: FAIL
VERDICT: GATE PASS
The starter passes five checks and fails three. The starter's soaking page shows v2=confirmed(good) while the soaking release has used one restart, which is exactly the false claim this lesson exists to prevent.
Read the reference for limits. From reading history.ts, the reference leaves these gaps. They are not covered by the checker, and the exercises below were not run:
exits_in_windowandwindow_mson a rollback line are displayed or ignored but never checked.exits_in_windowshould equal the restarts counted plus one, andwindow_msshould equal the report'ssoak_ms.- The page never recomputes the window from
ts. Aconfirmedline stamped before promotion plus the window would pass. The page trusts the deployer's timing and checks only the order and counts of its decisions. - If the candidate is the baseline itself (nothing was ever promoted), the
report.deploycomparison is skipped. statusTextbranches withif. Writing it as aswitchoverStatuswith aneverdefault would make the compiler flag any new status that is not handled.
Closing one of these gaps and adding a forged fixture for it is a good extension.
A note on the banner. The banner uses role="alert" with aria-live="assertive" for a rollback, role="status" with polite for soaking, and off for confirmed. MDN says not to use assertive "unless the interruption is imperative", and the WAI-ARIA alert pattern describes an alert as "a brief, important message" that attracts attention "without interrupting the user's task". This page is static, so nothing changes after load. These attributes express how urgent each message is, but this lesson does not test how any particular screen reader handles an alert that is present at load. It makes no claim there.
Check yourself
These questions use only the fixtures and output above, so you can answer each one by reading the lab.
- In the
slowburndeploy,v2exits three times in total, and the budget is two restarts. Why is it stillconfirmed? Hint: the window ends at 31000 (promotion at 1000 plus 30000), the tick at 31500 is the first event past it and confirmsv2, and the third exit at 32000 comes after that, so the ledger records it asrestarted_post_soakand the page shows "1 restart after soak (not counted)". - The starter's
soakingpage printedv2=confirmed(good). Which single line in the starter caused that, and which check caught it? Hint: the status set toconfirmedat promotion;soaking_never_goodfailed. - The
forged_targetledger is valid JSONL, and every line hasts,eventandversion. What makes the build refuse it? Hint: the page tracks the last confirmed release itself (v1), and the second rollback claimsto: v2, a release that was itself rolled back. - Why would a check that greps the built HTML for the word "good" be wrong on this page? Hint: the soaking row says "not yet good" and carries
data-good="false"; the marker is thegoodclass token ordata-good="true".
4. Other paths
Gleam with Lustre (hypothetical sketch, not compiled or run). Lustre can be used as an HTML templating library without its runtime. Its documentation describes passing elements to functions like to_string_tree or to_document_string. It also describes text as turning "a Gleam string into a text node" and none as "for rendering nothing", useful for conditional rendering. Gleam's custom types make the three statuses explicit, and case expressions must cover every variant:
import gleam/int
import lustre/attribute
import lustre/element.{type Element}
import lustre/element/html
pub type Status {
Confirmed(restarts: Int)
Soaking(restarts: Int)
RolledBack(to: String, exits: Int)
}
pub type Release {
Release(version: String, status: Status)
}
// Good means confirmed. A new variant will not compile until it is placed here.
pub fn is_good(status: Status) -> Bool {
case status {
Confirmed(_) -> True
Soaking(_) | RolledBack(_, _) -> False
}
}
fn status_text(status: Status, max: Int) -> String {
let m = int.to_string(max)
case status {
Confirmed(n) ->
"confirmed: survived the soak window with " <> int.to_string(n) <> " of " <> m <> " restarts used"
Soaking(n) ->
"soaking: on probation, " <> int.to_string(n) <> " of " <> m <> " restarts used; not yet good"
RolledBack(to, exits) ->
"rolled back (budget): " <> int.to_string(exits) <> " exits inside the soak window, budget " <> m <> " restarts; traffic went back to " <> to
}
}
fn row(r: Release, max: Int) -> Element(msg) {
let class = case is_good(r.status) {
True -> "good"
False -> ""
}
html.tr([attribute.class(class)], [
html.td([], [element.text(r.version)]),
html.td([], [element.text(status_text(r.status, max))]),
])
}
Rendering would end in element.to_document_string. The compiler's exhaustiveness check is a real help: every status must be handled in is_good and status_text. It does not stop the starter's bug, which is choosing Confirmed at promotion time. A type system makes forgotten cases visible, but it cannot make the logic correct. The ledger walk and the report cross-check are still code you have to write, and the same forgeries would still have to fail the build.
A live page. If the page had to update while a deploy soaks, you would need either a server that re-reads the files or client JavaScript that polls them. This lesson deliberately builds neither. The static page is correct as of the build, and a soaking release on it stays marked soaking until a rebuild shows otherwise.
5. Citations
- Kubernetes documentation, Deployments. Sections "Min Ready Seconds" (the quoted
.spec.minReadySecondsdefinition and its default of 0), "Progress Deadline Seconds" (theProgressingcondition with reasonProgressDeadlineExceeded) and "Failed Deployment" (no action other than reporting a status condition). https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ (re-read 2026-11-09). - Kubernetes documentation, Pod Lifecycle.
CrashLoopBackOffas a display value in the Status field of some kubectl commands, not the Pod phase. https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/ (re-read 2026-11-09). - Beyer, Murphy, Rensin, Kawahara, Thorne (eds.), The Site Reliability Workbook, O'Reilly, 2018, ch. 16 "Canarying Releases". Sections "Requirements of a Canary Process" and "Choosing a Canary Population and Duration" (size and duration).
- Joe Armstrong, Making reliable distributed systems in the presence of software errors, KTH, 2003, ch. 6. The restart-intensity idea behind "N restarts inside a window".
- AWS Certified DevOps Engineer – Professional (DOP-C02) exam guide v1.6, Task Statement 1.4, "Implement deployment strategies for instance, container, and serverless environments" (canary and blue/green evaluation, troubleshooting deployment issues).
- Bun documentation, "File I/O".
Bun.fileandBun.writeare the recommended way to work with files, and formkdirorreaddiruse thenode:fsimplementation;await foo.text()gives the contents as a string. https://bun.sh/docs/api/file-io (fetched 2026-11-05). - Lustre documentation,
lustre/elementv5.7.1. Lustre as an HTML templating library viato_string_treeandto_document_string;text;none. https://hexdocs.pm/lustre/lustre/element.html (fetched 2026-11-05). The Gleam sketch in section 4 is hypothetical and was not compiled or run. - MDN Web Docs,
aria-live.polite,assertiveandoff, and the note not to useassertiveunless the interruption is imperative. https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live (fetched 2026-11-07). - W3C, WAI-ARIA Authoring Practices, Alert Pattern. https://www.w3.org/WAI/ARIA/apg/patterns/alert/ (fetched 2026-11-07).