Baobaobaolin.com
date
entry
017
topic
tooling
rev

Verify a deploy by content, not status codes

I gave this three sentences in the first entry. It deserves more, because that script is the only definition of "deployed" I actually trust.

A static site finished deploying — how do you know it worked? The usual answer is to hit a few URLs and look for 200s. The trouble with that answer is that the two most common failures both return 200:

  • A missing directory key — CloudFront's SPA fallback substitutes the homepage for a path it cannot find. Status 200, wrong content
  • A deploy that never landed — the old build is still being served. Status 200, stale content

A status check sees neither. Catching them means comparing content.

The title is the fingerprint

You do not need the whole page. <title> is enough because it satisfies three things at once: it differs per page, it sits in a predictable place, and it does not change with cache busters or timestamps.

Three steps:

  1. Walk every index.html in the local build output and extract its title
  2. Translate the file path into the live URL
  3. Fetch that URL, extract its title, compare
#!/usr/bin/env bash
set -uo pipefail          # deliberately no -e; see below
BASE="https://example.com"
DIST="dist"
fail=0

title_of() {   # first <title> from stdin
  tr '\n' ' ' | sed -n 's/.*<title>\(.*\)<\/title>.*/\1/p' | head -n1
}

while IFS= read -r f; do
  rel="${f#"$DIST"/}"; rel="${rel%index.html}"      # posts/foo/
  url="$BASE/$rel"
  want=$(title_of < "$f")
  got=$(curl -fsS --max-time 10 "$url" | title_of)

  if [ "$want" != "$got" ]; then
    printf 'MISMATCH %s\n  want: %s\n  got : %s\n' "$url" "$want" "$got"
    fail=$((fail + 1))
  fi
done < <(find "$DIST" -name index.html)

[ "$fail" -eq 0 ] && echo "OK: all pages match" || echo "$fail page(s) wrong" >&2
exit $(( fail > 0 ))

Three deliberate choices

No set -e. A verification script earns its keep by finishing and handing you the complete list of what is broken. Aborting on the first mismatch tells you there is a problem without telling you how many or where — and you have to run it again before you can plan the repair.

Print both want and got. Those two lines identify which failure it is: got showing the homepage title means a directory key problem, got showing this page's old title means the deploy did not land. Put the diagnosis in the error message rather than leaving it for the next investigation.

curl -f so 4xx and 5xx count as failures. Title comparison covers "200 with wrong content"; -f covers the status failures. You want both.

One more check: the build's identity

Title comparison proves each page's content is right, but an old article whose content never changed passes even if this deploy did nothing at all. A build-generated identifier closes that gap:

<meta name="build-sha" content="dea001c" />
EXPECTED=$(git rev-parse --short HEAD)
ACTUAL=$(curl -fsS "$BASE/" | sed -n 's/.*name="build-sha" content="\([^"]*\)".*/\1/p')
[ "$ACTUAL" = "$EXPECTED" ] || { echo "stale build: $ACTUAL != $EXPECTED" >&2; exit 1; }

The static-site version of a backend's /version, with the same rule attached: the value must come from the build, never from a person.

When to run it

Two moments, for different reasons.

As the last step of the deploy. After the invalidation — run it too early and you read stale edge caches, producing false failures. False failures are worse than no check, because they train you to ignore it.

Once a day, on a schedule. Less obvious and more important: some breakage happens later. The next sync --delete removes the directory keys the previous run restored, and that deploy passed at the time. A deploy-time check alone never sees it.

The most expensive instance I have hit: an AWS session expired midway through the loop that rewrites directory keys. Everything before it had already synced, so several pages simply disappeared. The script used set -e, so even the invalidation never ran. Nobody noticed at the time, because nothing was comparing content.

Do not compare whole pages

The tempting overreach is "just hash the entire HTML". Do not — a live page almost always differs subtly from the local one: an injected analytics snippet, hashed asset filenames, a comment the server added. Whole-page comparison produces a daily stream of false alarms, and false alarms end with the check being switched off.

A fingerprint should be stable and discriminating. Title is the best starting point; add <h1> or the canonical if you need more, knowing each addition buys some probability of a false alarm.

If you remember one thing

"Deployed" should be an assertion you can verify, not a side effect of a script not erroring. On a static site the only sufficiently strong verification is fetching the live content and comparing it to the copy in your hand. Thirty lines.

Revision history

  1. First published