Shipping

Your deploy output is a verification surface

A deploy that exits zero proves the commands ran, not that anything shipped. Print the counts that would differ between a real deploy and a no-op.

MobileByteSensei 10 min read
A gauge reading zero, which is both a correct instrument and no information at all
deployoperationsverification

A deploy finishes. Every step exits zero, the summary line is green, and nothing in the output is red.

That tells you the commands ran. It does not tell you whether anything shipped, and those are different claims that look identical at the bottom of a log.

The case this post is about

One deploy summary, and the five numbers that make it a record rather than a reassurance:

pages built        301
files changed      149
assets uploaded     18
URLs submitted     100  (49 dropped — daily quota)
version            from the manifest

The line this replaced said 0 files uploaded · 199 pages · 0 changed followed by deploy complete. Every word of that was true and it is also what a deploy prints when the build predates your work entirely — which is how fifteen posts shipped nothing while reporting success.

The fourth row is the one people leave out. Forty-nine URLs were dropped for a real reason; a summary that reports 100 submitted and stays silent about the 49 is a silent cap.

This post follows those five rows, because green means the commands exited zero.

The symptom

The sharpest version is a deploy that publishes nothing and reports success. The build directory was stale, so there was nothing new to upload; the deploy uploaded nothing, correctly, and said so in a sentence that reads as good news. Everyone's work was still sitting in a directory the deploy never looks at.

What makes this hard is that the output was accurate. There is no lie to catch. 0 changed is a true statement about a real directory, and the only thing wrong with it is that it answers a question nobody asked.

Two readings that look identical, and only one of them means the line is clear

The second shape is the partial deploy, which survives much longer because its numbers are plausible. Some pages shipped, some did not, the count looks reasonable, and nothing distinguishes "fourteen changed because fourteen changed" from "fourteen changed because the other nine were skipped". A number is only evidence if you know what it should have been.

The third is the step that silently does nothing. A submission loop with nothing to submit, a cache purge with no keys, a notification with no recipients — each of these completes instantly and successfully, and a step that does nothing looks exactly like a step that does its job well. Over time you stop reading them, which means the day one genuinely breaks you will not notice either.

The fourth is the archaeology problem. Three weeks later somebody asks what went out in a particular deploy. The log says it succeeded. It does not say what version, from which commit, how many pages, which URLs were submitted — so the record of the most consequential operation in the whole pipeline is the single least informative thing it produced.

Why the obvious fix failed

Add more logging. The instinct, and it makes things worse in a specific way: volume. A deploy that prints four hundred lines has not become more legible, it has become a thing nobody reads, and the one line that mattered is now harder to find than when there were twelve. Verbosity is not information density.

Check the live site afterwards. Correct and manual, which means it happens when you remember and exactly not on the day you were in a hurry. It also only catches the total failure. A partial deploy looks fine on whichever page you happen to open, and the page you would need to check is the one you do not know about.

Fail loudly on zero. Tempting — if nothing changed, that is suspicious, so error. It breaks immediately, because a genuine no-op deploy is a completely normal event: you redeploy after a config change, or re-run after a flake. Making a legitimate state an error trains people to ignore the error, which is strictly worse than not having it.

Diff the output against the last run. Closer, and it answers did this run differ from the previous one rather than did this run do what I meant. Two consecutive wrong deploys produce no diff at all, and that is precisely the case you are trying to catch.

The actual mechanism

Print the numbers that would be different between a real deploy and a no-op, and print them every time, in the same shape.

That phrasing is doing the work. Not "log what happened" — identify the quantities that distinguish the outcome you wanted from the plausible failures, and make those the output.

pages built        301
files changed      149
assets uploaded     18
URLs submitted     100  (49 dropped — daily quota)
version            from the manifest
commit             what it was built from

Three properties make a line like that useful rather than decorative.

Each number is a quantity somebody can be surprised by. 301 pages is not interesting on its own and is extremely interesting the day it reads 289. A number that is always the same is noise; a number that tracks something real is a tripwire you get for free, because people notice when a familiar figure moves.

An instrument whose needle sits where it always sits, so the day it does not is obvious

The dangerous zero is annotated, not just reported. 0 changed on its own is the ambiguous sentence that started this. 0 changed — build is current with content and 0 changed — build is 2 days older than content are different facts, and the deploy has both of them available. Printing the reason alongside the number is the difference between a line that reads as reassurance and one that reads as a fact.

A reading taken where the line is live, not where it happens to be convenient

Identity travels with the outcome. Version, commit, artifact hash. Those three turn the summary from a status into a record, and they are what makes the three-weeks-later question answerable without reconstructing anything.

The sub-case worth getting right: what to print when a step legitimately did nothing. Silence is wrong, because it is indistinguishable from the step not running. "Skipped" is better. "Skipped — no URLs changed since last deploy" is right, because it reports both the outcome and the reason, and the reason is the part that lets a reader tell a correct skip from a broken one.

The fix

The summary is a fixed shape, printed on every run, including the boring ones:

what was built          counts
what actually moved     counts, with the reason when zero
what it was             version, commit
where it went           host, and the URL a human can open

The zero case is the one the shape exists for — "0 changed" has to carry its reason or it reads as "nothing to do":

export function deploySummary(r: DeployResult) {
  const moved = r.uploaded + r.deleted;
  const reason =
    moved > 0                      ? ""
    : r.builtAt < r.sourcesChangedAt ? "  (build predates your changes — run generate first)"
    : r.distDigest === r.lastDigest  ? "  (build output identical to the last deploy)"
    :                                  "  (reason unknown — this should not happen)";

  return [
    `built      ${r.pages} pages`,
    `moved      ${r.uploaded} uploaded, ${r.deleted} deleted${reason}`,
    `version    ${r.version} (${r.commit.slice(0, 8)})`,
    `live at    ${r.url}`,
  ].join("\n");
}

The same deploy, with and without the reason attached:

$ deploy                      # before
  0 files uploaded · 199 pages · 0 changed
  deploy complete

$ deploy                      # after
built      199 pages
moved      0 uploaded, 0 deleted  (build predates your changes — run generate first)
version    2.4.0 (9f2c1b05)
live at    https://example.com

The first output is what a successful no-op and a never-looked-at-your-changes failure both print. Fifteen posts shipped nothing on the strength of that line reading as "nothing to do". what was skipped and why


The discipline is that the shape does not change between a successful deploy and an uneventful one.
A summary that only appears when something interesting happened has made its absence meaningful, and
absence is the thing humans are worst at noticing.

Where the summary goes matters as much as what is in it. Printed at the top, it describes intentions
and is read before anything has happened; printed at the very bottom, after whatever wall of output
the tooling produced, it is where a person's eye lands when the command returns. That is the one
position in a log that is reliably read, and spending it on a generic success line is a waste of the
only attention the deploy is guaranteed to get.

The same argument applies to failures, in reverse. A deploy that fails should print this block too,
reporting how far it got — built but not uploaded, uploaded but not submitted — because the question
after a failure is always *what state am I in now*, and a stack trace does not answer it.

It is also worth being deliberate about length. This is a handful of lines, not a report — the whole
value is that somebody actually reads it, and that only survives if reading it costs two seconds. Any
line that is always identical is a candidate for deletion, because it is spending attention without
ever carrying information.

## How we proved it

**The incident that produced this is specific.** A deploy reported `301 pages · 0 changed` and
finished green. Fifteen posts had just had new frontmatter written into them. The deploy was correct
about the directory it read, and the directory was stale. After regenerating, the same deploy on the
same content reported `301 pages · 15 changed`, and the links went live.

One command's difference, and the only observable signal beforehand was a number that looked like
good news. That pair of numbers — the same deploy, before and after — is the whole argument, because
it shows the output distinguishing the two states perfectly while the exit code did not.

**The quota line is the other half**, and it is the case where the output did its job. A submission
step reported that one engine accepted a sitemap and the other refused 149 URLs against a 100-a-day
allowance, naming the condition. Nothing failed; a real limitation was stated as a fact in the normal
output, which is what you want from a summary that is read in two seconds.

**What I have not measured** is whether anybody reads it under pressure. The design assumes a person
scans four lines and notices an unfamiliar number, and I have no evidence about that happening on a
bad day as opposed to a calm one. It is a hypothesis about attention dressed up as a mechanism, and
the honest framing is that it costs almost nothing and might help.

**What I have not solved:** nothing checks the numbers. Every improvement here makes the output more
*legible to a human*, and a human is still the comparator. The obvious next step is for the deploy to
hold expectations — pages built should be within a few of last time, changed should be non-zero when
content is newer than the build — and refuse or warn on its own. I have the numbers and have not made
anything assert on them, which means this post describes a better dashboard rather than a check.

![Four readings taken every run, and the one that moved](/posts/what-a-deploy-should-print/anim-readings.gif)

**And a limitation of the whole approach:** a summary describes what the deploy believes it did. If
the upload succeeded and the CDN served a stale copy, every number above is correct and the site is
wrong. Verifying the outcome rather than the operation means fetching the live thing and checking it,
which is a different and stronger mechanism that this does not replace.

## What this generalizes to

For any operation whose success is reported as a status, ask: **what would be different in the output
if this had silently done nothing?**

If the answer is "nothing", the status is not evidence. That applies well beyond deploys — a sync, a
migration, a backup, a cache warm, a batch job. All of them can complete successfully over an empty
set, and all of them print the same cheerful line when they do.

It is worth noticing how cheap this is relative to the alternatives. Everything else that catches a
silent no-op — a smoke test against the live site, a content hash comparison, an end-to-end check —
is real engineering with real maintenance attached. Printing four numbers you already computed is an
afternoon, and while it is strictly weaker than any of those, it is the thing that actually gets
built, and a weak check that exists beats a strong one that is still on the list.

The fix is small and it is not really about logging. Find the two or three quantities that a reader
could be surprised by, print them unconditionally, and annotate the zero. Then look at anything in
your output that reads as reassurance — *up to date*, *nothing to do*, *no changes* — because those
are the sentences with two meanings, and nobody has ever investigated one.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS