A release history that never lies about what is live A static export in Bun and TypeScript
Every release shows its outcome and the reason from the log. Only a promotion moves the live marker, and a log the page cannot read names no live release.
F-DevOps · A · arc: self-healing-edge · langs: ts, gleam · DOP-1.4
By Evan Eugene Pincham.
Concept
A deploy is a hypothesis: "the new release works." You test it before you believe it. The test used here is readiness inside a deadline. The new release starts next to the old one, and a small program (the deployer) asks the new release "are you ready?" every 100 ms. If the answer is yes before the deadline, the new release replaces the old one and the deployer logs promoted. If the deadline passes, or the new release dies at boot, the deployer stops it, confirms the old release still answers, and logs rolled_back with the reason. The old release is kept until the test passes. A rollback in this design is not a repair. It is simply the old release never having been taken away.
That log is the only record of what happened. This lesson is about the reader of that record: a release history page. Its job is easy to state and easy to get wrong. It must show each release with its outcome and reason, and it must name at most one release as live. A page that says "live" for a release that was rolled back is worse than no page, because someone will believe it.
Four rules keep the page honest. Each is one decision in the code you will read.
- **Only a
promotedevent moves the live pointer.** This mirrors the deployer, where only a promotion changes which release is current. A release that merely started is not live. - A release shows its latest known event. A candidate with a start line and no outcome line is in progress. It is never live and never promoted.
- A line the page cannot read makes the whole page "unknown" and clears the live pointer. If a line is missing or cut off, you cannot rule out that it was the line that mattered. Saying "I cannot tell" is the honest answer.
- The reason comes from the log, not from the page. The page displays the log's
detailtext. It does not guess or invent a cause.
One more point is easy to miss. Kubernetes has a deadline concept of its own, and it only reports. In its Deployments documentation, under "Failed Deployment", it notes that Kubernetes takes no action on a stalled Deployment other than to report a status condition with reason ProgressDeadlineExceeded. Rolling back is a separate act: the "Rolling Back a Deployment" section describes kubectl rollout undo, which a person or a higher-level tool runs. The lesson for a page builder is that a deadline exceeded signal is not a rolled back fact. Your page has to read what the deployer actually did.
The Site Reliability Workbook, chapter 16 ("Canarying Releases"), makes the matching operational point. Having no way to roll back prolongs user impact, and smaller release artifacts are cheaper to roll back. In its blue/green discussion, rollback becomes "a trivial reversal of the router change." That is the same shape as here. A release is only as safe as the cost of undoing it, and the log has to say plainly whether the undo happened.
Last, why a static export? The page is a content site. It has no accounts and no data store of its own. Its only input is a log file, which is read at build time. With nothing to log in to and nothing to query, there is no reason to run a server. The build writes plain HTML and CSS files to a folder that any static host can serve. The cost is staleness: the page is as fresh as the last build, so a real setup would rebuild after every deploy. That hook is not part of this lab.
Worked example
The shared example is a deployer that starts a new release beside the old one and promotes it only if it reports ready before a deadline. This lane reads that deployer's log and turns it into a release history page: first the log itself, then the TypeScript that decides what the page may claim, then the Bun script that writes the HTML.
The input: a four-field record
The deployer writes one JSON object per line with a fixed schema: ts (Unix seconds), event, version, detail. The three events this page understands are candidate_started, promoted and rolled_back. Here is real output from a run of the deployer: version 2 is promoted, version 3 never becomes ready within its 1.5 s deadline, and version 4 crashes at boot with exit code 78.
$ cat fixtures/rollback.log
{"ts":1793632142,"event":"candidate_started","version":"v2","detail":"pid 997 port 18092"}
{"ts":1793632142,"event":"promoted","version":"v2","detail":"replaced v1 on port 18091"}
{"ts":1793632143,"event":"candidate_started","version":"v3","detail":"pid 1034 port 18093"}
{"ts":1793632144,"event":"rolled_back","version":"v3","detail":"not ready after 1500 ms; v2 still serving on port 18092: true"}
{"ts":1793632145,"event":"candidate_started","version":"v4","detail":"pid 1068 port 18094"}
{"ts":1793632145,"event":"rolled_back","version":"v4","detail":"candidate exited with exit status: 78 before ready; v2 still serving on port 18092: true"}
Two other fixtures go with it. clean.log is just the first two lines (v2 started, then promoted). garbled.log is the first four lines with the fourth cut off mid-write, ending at "detail":"not ready af. A truncated log like this is what you might see if the deployer was killed while writing, or a disk filled up.
Reading the log: releases.ts
// Reads the deployer's JSON log (ts, event, version, detail) and decides what the page may claim.
export type Outcome = "promoted" | "rolled_back" | "in_progress";
export type Release = { version: string; outcome: Outcome; ts: number; detail: string; live: boolean };
export type History = { state: "known" | "unknown"; releases: Release[]; live: string | null; bad: number };
const KNOWN = new Set(["candidate_started", "promoted", "rolled_back"]);
export function parse(text: string) {
const events: { ts: number; event: string; version: string; detail: string }[] = [];
let bad = 0;
for (const line of text.split("\n")) {
if (line.trim() === "") continue;
try {
const e = JSON.parse(line);
const ok = typeof e.ts === "number" && typeof e.version === "string"
&& typeof e.detail === "string" && KNOWN.has(e.event);
if (ok) events.push(e); else bad++;
} catch {
bad++;
}
}
return { events, bad };
}
export function history(text: string): History {
const { events, bad } = parse(text);
const byVersion = new Map<string, Release>();
let live: string | null = null;
for (const e of events) {
const outcome: Outcome = e.event === "candidate_started" ? "in_progress" : (e.event as Outcome);
byVersion.set(e.version, { version: e.version, outcome, ts: e.ts, detail: e.detail, live: false });
if (e.event === "promoted") live = e.version; // only a promotion moves the live pointer
}
if (bad > 0) live = null; // a log we cannot fully read names no live release
if (live !== null && byVersion.get(live)?.outcome !== "promoted") live = null; // header agrees with rows
const releases = [...byVersion.values()];
for (const r of releases) r.live = r.version === live && r.outcome === "promoted";
return { state: bad > 0 ? "unknown" : "known", releases, live, bad };
}
Walk through it with the rules in mind.
**parse is the schema check (the Data half).** A line is good only if it is valid JSON, ts is a number, version and detail are strings, and event is one of the three known names. Anything else adds one to bad. JSON.parse throws a SyntaxError on malformed text (MDN, "JSON.parse()", Exceptions), and that is exactly what a truncated line does, so the catch counts it. The try covers more than the parse. A line holding the JSON text null parses successfully, but then e.ts throws a TypeError. That is also caught and counted, so no valid-JSON-but-wrong-shape line slips through. The check is deliberately strict. An unfamiliar event name is a bad line, not a shrug, because a new event kind might be one that changes who is live.
**history is the policy (rules 1 to 4).** Events are applied in log order. byVersion.set replaces the entry for a version each time, so the row ends up showing that version's latest known event (rule 2). A candidate_started becomes in_progress. The line if (e.event === "promoted") live = e.version is rule 1 and the only place live is assigned a version. The line if (bad > 0) live = null is rule 3. The next line makes the header agree with the rows: if the version named live no longer ends in promoted, the pointer is cleared. The last loop adds a second guard on the flag: a row is live only if it is the live version and its own outcome is promoted.
That is why clean.log yields one row, not two. candidate_started and promoted for v2 collapse into a single release whose latest event is promoted.
Writing the page: build.ts
// Static export: one release-history page per fixture log, written to dist/. No server, no client JS.
import { readdirSync } from "node:fs";
import { history } from "./releases.ts";
const LABEL = { promoted: "Promoted", rolled_back: "Rolled back", in_progress: "In progress" };
const esc = (s: string) =>
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
const iso = (ts: number) => new Date(ts * 1000).toISOString().replace(".000Z", "Z");
for (const file of readdirSync("fixtures").filter((f) => f.endsWith(".log")).sort()) {
const h = history(await Bun.file(`fixtures/${file}`).text());
const rows = h.releases.map((r) =>
`<li class="release outcome-${r.outcome}" data-version="${esc(r.version)}" data-outcome="${r.outcome}"${r.live ? " data-live" : ""}>` +
`<b>${esc(r.version)}</b> ${LABEL[r.outcome]}${r.live ? ' <span class="live">live</span>' : ""} ` +
`<time>${iso(r.ts)}</time> <span class="detail">${esc(r.detail)}</span></li>`).join("\n");
const head = h.state === "unknown"
? `Cannot tell which release is live: ${h.bad} unreadable line(s) in the deploy log.`
: `Live release: ${h.live ?? "none recorded in this log"}.`;
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>edge-svc releases</title>
<link rel="stylesheet" href="releases.css"></head>
<body><main data-state="${h.state}">
<h1>edge-svc release history</h1>
<p>${esc(head)}</p>
<ol>
${rows}
</ol>
</main></body></html>
`;
const out = `dist/${file.replace(/\.log$/, ".html")}`;
await Bun.write(out, html);
console.log(`${file} -> ${out} state=${h.state} live=${h.live ?? "none"} releases=${h.releases.length}`);
}
await Bun.write("dist/releases.css", await Bun.file("releases.css").text());
Things to notice:
- Bun reads with
Bun.file(path).text()and writes withBun.write(path, data)(Bun docs, "File I/O"). The script never createsdist/itself, and the recorded run below showsBun.writeworking into a folder that did not exist before. - The
detailtext is escaped withescbefore it goes into HTML. It is data from a file, not markup. If a log line contained<script>, it would appear as visible text instead of running. - The live marker is a plain attribute,
data-live, plus a visible "live" badge. Thedata-outcomeattribute carries the outcome in machine-readable form, and the checker reads those attributes. - There is no client-side JavaScript. The export is finished HTML.
What the build prints
This is the checker's output on the reference solution, captured in a clean lab container with Bun 1.3 and networking disabled (the build lines come first):
$ bun build.ts
clean.log -> dist/clean.html state=known live=v2 releases=1
garbled.log -> dist/garbled.html state=unknown live=none releases=2
rollback.log -> dist/rollback.html state=known live=v2 releases=3
built 3 pages
RESULT static_build PASS
rollback outcomes: v2=promoted v3=rolled_back v4=rolled_back
RESULT outcomes_distinct PASS
rolled-back rows marked live: 0; live rows on rollback page: 1
RESULT rolled_back_never_live PASS
RESULT reason_shown PASS
garbled: state=unknown live rows=0
RESULT unreadable_names_no_live PASS
Read the garbled line carefully. The log has four lines and the fourth is cut off. The page has two releases (v2 and v3). v3 shows as in progress, because its rolled_back line is the one that is unreadable, so all the page can honestly say is that v3 started. v2 still shows as promoted, because that line is intact. But bad is 1, so live is cleared and no row carries the live badge. The page header says it cannot tell which release is live. In truth v2 probably is live. The page declines to say so, because the damaged line could have been something that changed that.
Why the header has its own guard
An earlier version of history had only the row guard. Feed it a log where v2 is promoted and later rolled back for the same version:
$ cat fixtures/undo.log
{"ts":1793632142,"event":"candidate_started","version":"v2","detail":"pid 997 port 18092"}
{"ts":1793632142,"event":"promoted","version":"v2","detail":"replaced v1 on port 18091"}
{"ts":1793632150,"event":"rolled_back","version":"v2","detail":"example: operator undo"}
Without the line marked header agrees with rows, the build printed undo.log -> dist/undo.html state=known live=v2 releases=1. The row said data-outcome="rolled_back" with no badge, and the header said "Live release: v2." Two statements on one page, contradicting each other. With the guard, the same log prints live=none and the header reads "Live release: none recorded in this log." The deployer in this arc never writes that sequence, and the three lab fixtures do not contain it, but a page that reads logs should not depend on the writer being perfect.
Lab
You have a starter project with the same shape as the reference: releases.ts, build.ts, releases.css, three fixture logs, and a checker, check.sh. The starter's build.ts is identical to the reference. Only releases.ts needs work, and you can start it from a blank file. The checker, check.sh, sits one level up in lab/. It rebuilds dist/ and prints one RESULT line per check:
static_build:bun run buildwrites three pages.outcomes_distinct: on the rollback page, v2 ispromotedand v3 and v4 arerolled_back, read from thedata-outcomeattribute.rolled_back_never_live: no rolled-back row on any page carriesdata-live, and the rollback page has exactly one live row, v2.reason_shown: both rollback reasons from the log appear on the rollback page.unreadable_names_no_live: the garbled page hasdata-state="unknown"and no live row.
Steps
- Run the checker on the starter first. From inside the starter directory, run
sh ../check.sh. It needs Bun installed. It deletes and rebuildsdist/, so it is safe to repeat.
cd lab/starter
sh ../check.sh
Expect static_build to pass and the other four checks to fail. Read the failures before you touch code. The starter's history has two faults that are visible at a glance: it turns every candidate_started event into a release with outcome: "promoted" and live: true, and it ignores every other event. So the rollback page marks all three releases live and its build line reports live=v4, and the reason text is the start line's detail ("pid 1068 port 18094"), not the rollback reason.
- Predict, then fix, one check at a time. Before each edit, say which check will flip and why. A suggested order: - Outcomes. Read the
eventfield and map it:candidate_startedbecomesin_progress, whilepromotedandrolled_backmap to themselves. Keep one row per version and let a later event replace an earlier one. Re-run:outcomes_distinctandreason_shownshould now pass. - Live pointer. Set the live version only when you see apromotedevent. Mark a row live only if it is that version. Re-run:rolled_back_never_liveshould pass. - Unreadable lines. Count lines that fail to parse or fail the schema check, and when the count is above zero setstateto"unknown"and clearlive. Re-run:unreadable_names_no_liveshould pass.
- Compare with the reference only when you are done. Your code does not need to match the reference line for line. It needs to follow the four rules and pass the five checks.
Stretch
- Promote, then undo. In a scratch copy of the lab (a fourth fixture would break the checker's "3 pages" test), add the three-line log from "Why the header has its own guard" and confirm your header and rows both say no release is live.
- Think about the cost of "unknown". The page above goes to "unknown" if any line is bad, even one from long ago. Write two or three sentences on when that is the right call and when it would drive people to stop trusting the page. There is no single right answer, but your answer should say who is hurt by each choice.
- Escape check. Add a fixture whose
detailcontains<b>bold</b>and confirm the built HTML shows it as text.
Other paths
The Gleam notes below are hypothetical: this code has not been compiled or run, and library details should be checked against the docs before you rely on them.
Gleam with Lustre. Lustre is a Gleam library for building HTML. Its element module can turn an element tree into a string, with to_string for a fragment and to_document_string for a full document (Lustre docs, lustre/element). That gives you the same static export: build the list in Gleam, render it to a string, write it to a file. The interesting change is in the types. A custom type lets you make the forbidden state impossible to write down:
// Hypothetical sketch, not compiled.
import lustre/attribute
import lustre/element
import lustre/element/html
pub type Release {
// `live` exists only here. A RolledBack release has no such field.
Promoted(version: String, ts: Int, detail: String, live: Bool)
RolledBack(version: String, ts: Int, reason: String)
InProgress(version: String, ts: Int, detail: String)
}
fn row(r: Release) -> element.Element(msg) {
case r {
Promoted(version: v, detail: d, live: l, ..) ->
html.li(
case l {
True -> [attribute.attribute("data-version", v), attribute.attribute("data-outcome", "promoted"), attribute.attribute("data-live", "")]
False -> [attribute.attribute("data-version", v), attribute.attribute("data-outcome", "promoted")]
},
[html.text(v <> " Promoted " <> d)],
)
RolledBack(version: v, reason: why, ..) ->
html.li(
[attribute.attribute("data-version", v), attribute.attribute("data-outcome", "rolled_back")],
[html.text(v <> " Rolled back " <> why)],
)
InProgress(version: v, detail: d, ..) ->
html.li(
[attribute.attribute("data-version", v), attribute.attribute("data-outcome", "in_progress")],
[html.text(v <> " In progress " <> d)],
)
}
}
// element.to_string(html.ol([], list.map(releases, row))) would give the list as a string.
What changes from the TypeScript version:
- In the TypeScript reference, "a rolled-back release is never live" is enforced by one expression:
r.version === live && r.outcome === "promoted". If someone edits that line carelessly, the rule breaks silently. In the Gleam sketch,liveis a field onPromotedonly, so code that tries to render a rolled-back release as live does not type-check. (TypeScript could get the same effect with a discriminated union where only thepromotedmember has alivefield. The reference uses a flat type for simplicity, and that choice is untested here.) - The compiler also makes you handle every variant in the
case. If you add a fourth outcome later, every renderer that forgot it fails to compile. - The type does not enforce everything. "Only a promotion moves the live pointer" and "an unreadable log clears it" are still logic you must write in the function that builds the
Releasevalues. A different design could carry that in the type too, for example with a page type whose "unknown" variant holds releases without any live field. That is a design to try, not something verified here. - Escaping is different too: Lustre builds elements and text nodes, and
html.textis rendered as text rather than as raw markup. If it escapes text the way text nodes normally do, the hand-writtenescgoes away; confirm that against the library docs before relying on it.
Citations
- Kubernetes documentation, "Deployments", https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ . Sections "Rolling Back a Deployment" (
kubectl rollout undo), "Progress Deadline Seconds", and "Failed Deployment" (the note that Kubernetes takes no action on a stalled Deployment other than to report a status condition with reasonProgressDeadlineExceeded). - Beyer, Murphy, Rensin, Kawahara, Thorne (eds.), The Site Reliability Workbook, O'Reilly, 2018, chapter 16 "Canarying Releases": "Release Engineering Principles" (smaller artifacts are cheaper to roll back), "Balancing Release Velocity and Reliability" (no rollback option prolongs user impact), and the blue/green paragraph ("rollback is a trivial reversal of the router change").
- Beyer, Jones, Petoff, Murphy (eds.), Site Reliability Engineering, O'Reilly, 2016, chapter 8 "Release Engineering".
- Bun documentation, "File I/O", https://bun.sh/docs/api/file-io (
Bun.file,Bun.write). - MDN Web Docs, "JSON.parse()", https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse (Exceptions:
SyntaxError). - Lustre documentation,
lustre/element, https://hexdocs.pm/lustre/lustre/element.html (to_string,to_document_string).