Hedronite · Curriculum · DevOps · Frontend · Mon 2026-11-02

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.

Lesson Class: Curriculum · F-DevOps · rung A
Tested with: Bun 1.3 (lab image) · Bun 1.4.2
Lab: lab/ · sh check.sh (5 checks) · no network
Source: Kubernetes Deployments · SRE Workbook ch. 16 · Bun File I/O · Lustre element
Promotion Moves the Pointer
Only a promoted event can make a release live, the same way only a rename moves the deployer's current file.
The Reason Comes From the Log
The page repeats the deployer's own words for why a release rolled back.
Unknown Names No One
A log with an unreadable line gets no live marker at all.
A history page that guesses which release is live will one day show the release that was rolled back.

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.

  1. **Only a promoted event 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.
  2. 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.
  3. 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.
  4. The reason comes from the log, not from the page. The page displays the log's detail text. 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
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:

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:

  1. static_build: bun run build writes three pages.
  2. outcomes_distinct: on the rollback page, v2 is promoted and v3 and v4 are rolled_back, read from the data-outcome attribute.
  3. rolled_back_never_live: no rolled-back row on any page carries data-live, and the rollback page has exactly one live row, v2.
  4. reason_shown: both rollback reasons from the log appear on the rollback page.
  5. unreadable_names_no_live: the garbled page has data-state="unknown" and no live row.

Steps

  1. Run the checker on the starter first. From inside the starter directory, run sh ../check.sh. It needs Bun installed. It deletes and rebuilds dist/, 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.

  1. Predict, then fix, one check at a time. Before each edit, say which check will flip and why. A suggested order: - Outcomes. Read the event field and map it: candidate_started becomes in_progress, while promoted and rolled_back map to themselves. Keep one row per version and let a later event replace an earlier one. Re-run: outcomes_distinct and reason_shown should now pass. - Live pointer. Set the live version only when you see a promoted event. Mark a row live only if it is that version. Re-run: rolled_back_never_live should pass. - Unreadable lines. Count lines that fail to parse or fail the schema check, and when the count is above zero set state to "unknown" and clear live. Re-run: unreadable_names_no_live should pass.
  1. 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

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:

Citations