Skip to content

Free tool

release-diff — what changed between two renderings of the same site

release-diff reads the same list of paths on two hosts — staging and production, or the tree you are about to publish and the one that is live — and reports every field that moved. A change you meant to make is declared in a file, one path field per line, which is the same shape as a ticket. A change nobody declared fails the run. Nothing is scored and there is no pass mark: either everything that moved was written down, or it was not.

  • Staging vs production
  • 11 fields
  • Declared changes
  • Exit 3 on a surprise
  • Read-only
  • Python 3

Install and run

One file, standard library only — no crawler to install, no headless browser, no account. Python 3.8 or newer; it was written and run here on Python 3.12, which is the only version it has been executed on.

# download it next to wherever you are working
curl -O https://plantroomlabs.com/tools/release-diff.py

# the list of paths to compare, one per line
printf '/
/pricing/
/blog/
' > paths.txt

# staging against production
python3 release-diff.py --old https://staging.example.com                         --new https://example.com --paths paths.txt

# the same run, with the changes the ticket asked for declared
python3 release-diff.py --old https://staging.example.com                         --new https://example.com --paths paths.txt                         --expect declared.txt

It honours robots.txt on both hosts and skips any path either side disallows, reporting ROBOTS rather than guessing. It sends no cookies, runs no JavaScript, and writes nothing anywhere. Exit status is 0 when nothing moved that was not declared and 3 when something did, so it drops into a pipeline without a wrapper.

What it reads, per path, from both sides

FieldWhat it is
statusThe HTTP status after redirects. A page that stopped answering moves this field and nothing else.
redirectThe chain of locations, by path, so a redirect that grew a hop or started pointing somewhere else is visible as a chain rather than as a final status.
title<title>, whitespace-flattened.
h1The first <h1>'s text, read through inline markup.
has_h1Whether there is an <h1> at all — a separate field on purpose. The failure this exists for is an h1 that stopped being an h1 because a div sat better in the layout, and a tool that compares only heading text reads that as the text going missing.
canonical<link rel=canonical>.
robots<meta name=robots> and the X-Robots-Tag header, merged, because either one can carry a noindex and only one of them is in the markup.
description<meta name=description>.
linksThe set of same-host link targets, as paths. This is the field that catches a URL pattern that moved, and the one that catches internal links that ended up somewhere a crawler reads differently. Reported as the difference — one added or removed line per path — because a set of four hundred links printed as two strings is two strings that look the same.
tagsWhich measurement snippets are present, by vendor — GA4, GTM, Universal, Ads, Plausible, Matomo, Fathom, Clarity, Hotjar, Meta, LinkedIn, Segment, PostHog. Identified by the string the vendor requires, not by filename, so moving the file does not read as removing the tag.
jsonldThe set of @type values in clear-text JSON-LD.

A real release, of this site

The run below is not a fixture. It is the release that published this page, run before it went out: live plantroomlabs.com as --old, the build waiting on disk as --new, over nine paths. Three of them moved. The home page and the tools hub both gained a link to this page, the hub's heading and description changed because thirteen tools became fourteen, and how this site is built restated its own two figures — 23,004 assertions to 23,346, and 268 deliberate sabotages to 271:

CHANGED  /
    links       UNDECLARED added:   /tools/release-diff/
CHANGED  /how-this-site-is-built/
    description UNDECLARED old: One Python file, no framework, 23,004 assertions and 268 sabotages proving them - plus the two accessibility failures Lighthouse found on our own pages.
                           new: One Python file, no framework, 23,346 assertions and 271 sabotages proving them - plus the two accessibility failures Lighthouse found on our own pages.
SAME     /work/
SAME     /services/
SAME     /contact/
SAME     /handoff/
SAME     /support/
CHANGED  /tools/
    h1          UNDECLARED old: Free tools for reading a building's network, and its paperwork
                           new: Free tools for reading a building's network, its paperwork, and a release before it ships
    description UNDECLARED old: Thirteen free read-only command-line tools: a BACnet/IP sweep, an MQTT tap, a LoRaWAN decoder check, nine Niagara scanners and an Excel workbook audit.
                           new: Fourteen free read-only command-line tools: a BACnet/IP sweep, an MQTT tap, a LoRaWAN decoder check, nine Niagara scanners and an Excel workbook audit.
    links       UNDECLARED added:   /tools/release-diff/
SAME     /llms.txt

9 path(s) read, 3 changed, 5 undeclared field change(s)
FAIL: a release that changes these fields without declaring them is the failure this tool exists for.

That is the tool doing its job on its author, and it is worth being plain about what it means: every one of those five changes was deliberate, and the gate failed the release anyway, because at that point none of them had been written down. Declaring them is five lines. The comment above each one is for the human reading the file in three months, and writing it is the part that catches the change nobody meant:

# What this release changed and why, one line per field, which is the
# same shape as a ticket. Written by capture.py from the reasons in it,
# and the capture stops if the release moved a field no reason covers.
#
# The figures below went from 23,004 assertions and 268 sabotages to
# 23,346 and 271.
# The home page links the tool page published in this release.
/ links
# This page publishes check.py's assertion count and mutate.py's sabotage count, and both moved with the release.
/how-this-site-is-built/ description
# Thirteen tools became fourteen.
/tools/ description
# The hub's heading now says what the fourteenth tool reads, which is a release rather than a building.
/tools/ h1
# The hub links the tool page published in this release.
/tools/ links

The same run, with that file passed as --expect:

CHANGED  /
    links       declared   added:   /tools/release-diff/
CHANGED  /how-this-site-is-built/
    description declared   old: One Python file, no framework, 23,004 assertions and 268 sabotages proving them - plus the two accessibility failures Lighthouse found on our own pages.
                           new: One Python file, no framework, 23,346 assertions and 271 sabotages proving them - plus the two accessibility failures Lighthouse found on our own pages.
SAME     /work/
SAME     /services/
SAME     /contact/
SAME     /handoff/
SAME     /support/
CHANGED  /tools/
    h1          declared   old: Free tools for reading a building's network, and its paperwork
                           new: Free tools for reading a building's network, its paperwork, and a release before it ships
    description declared   old: Thirteen free read-only command-line tools: a BACnet/IP sweep, an MQTT tap, a LoRaWAN decoder check, nine Niagara scanners and an Excel workbook audit.
                           new: Fourteen free read-only command-line tools: a BACnet/IP sweep, an MQTT tap, a LoRaWAN decoder check, nine Niagara scanners and an Excel workbook audit.
    links       declared   added:   /tools/release-diff/
SAME     /llms.txt

9 path(s) read, 3 changed, 0 undeclared field change(s)
PASS: nothing moved that was not written down.

Note what the second run still prints. Every changed field is still reported, with both values, and all three pages are still marked CHANGED — the fields read declared rather than UNDECLARED, and that is the only difference. A gate that hides a declared change teaches you to declare things to make it quiet.

Why a diff, and not a checklist

The list of fields above is not ours. Asked what actually gets an outsourced developer dropped, an agency owner who buys development this way answered that it is not bad code: it is silent changes. A URL pattern that moved. A dropped canonical. An h1 turned into a styled div because it sat better in the layout. A noindex that never came off. A redirect tested on staging and never checked against the live box. An analytics tag quietly not firing. The site works perfectly afterwards; rankings slide three weeks later and by then nobody connects it to the release. It costs a client relationship rather than a bug ticket.

The same person named why a launch checklist does not fix it, and the reasoning is the whole design of this tool: a checklist only catches what somebody already thought to write down, and nobody writes down the failure they have not yet suspected. A diff has no such limit. It does not need anyone to decide in advance what matters — it reports everything that moved and asks you to account for it, which is the opposite direction of work.

That is also why this is eleven fields and not five hundred. Each one is a thing a release has been observed to break while looking fine, and the set is small enough that a report you cannot explain is worth reading rather than worth muting.

The host problem, which is the one real subtlety

A correctly configured staging site points its canonical at production, and writes its internal links in its own host. Compare those two raw and you get a difference on every page, every time — at which point the gate is noise, and a noisy gate is a gate somebody turns off. So both base hosts are folded to a single token before anything is compared, and neither base host can itself be a finding.

A canonical pointing at a third host still is. That distinction is the difference between a tool that works on a real staging setup and one that only works on a copy of production, and it is the piece most likely to be removed by someone simplifying the comparison.

The one thing it reports without comparing anything

A redirect that names its own URL in Location is not a redirect. The browser follows it until it hits its own limit and then shows the visitor nothing, and no amount of comparing will surface it, because it is the same nothing on both sides. Every field matches, and the path reads SAME — the one shape a diff is blind to by construction. This is not hypothetical: reading every URL one UK manufacturer's sitemap submits, on 6 October 2026, found eleven of fifty-four answering 301 with their own address, one of them the privacy policy their home page links twice.

So it is reported absolutely rather than diffed. A path whose chain comes back to a URL it already requested is BROKEN, named per side, and it fails the run on its own — --expect cannot declare one, because no release is meant to contain one. The run below reads two paths on one host, where the second answers 301 to itself:

SAME     /
BROKEN   /a-path-that-redirects-to-itself/
    redirect    LOOP       old: redirect loop: 301 to /a-path-that-redirects-to-itself/, which was already requested
    redirect    LOOP       new: redirect loop: 301 to /a-path-that-redirects-to-itself/, which was already requested

2 redirect loop(s)

2 path(s) read, 0 changed, 0 undeclared field change(s)
FAIL: a redirect loop shows the visitor nothing, and --expect cannot declare one - no release is meant to contain it.

Both sides are the same host there on purpose. Had the loop been on one side only, the status field would have moved and an ordinary diff would have caught it; it is the symmetrical case that needs its own reading.

What it does not do

It reads the HTML the server sends. A change made by JavaScript after load is invisible to it, so a measurement tag that is present in the markup and fails to fire still reads as present: the field is the snippet is on the page, not the measurement arrived. If what you need is the second thing, this is the wrong instrument and a real browser is the right one.

Two renderings taken minutes apart can also differ for honest reasons — a date, a counter, a rotating testimonial. That is what --expect is for, and a field that always moves belongs in the declared file with a comment saying why, rather than removed from the tool.

It is not a crawler. It reads the paths you give it and discovers nothing, because the question at release time is whether these pages changed, and a crawl that finds different pages on the two sides cannot answer it.

A diff that has never caught anything is decoration

A diff run over two identical trees reports nothing, and proves nothing, because the trees are identical. So the proof is deliberate sabotage, which is the same reading the mutation catalogue for this site rests on. The test below serves a copy of this site's released tree twice over loopback, applies each of the seven failures named above to one side on its own, and asserts the tool names the field that moved — plus the loop below, plus a control where the tree is compared against itself:

ok   control: a tree against itself reports nothing changed
ok   a canonical is dropped             -> canonical
ok   an h1 becomes a styled div         -> has_h1
ok   a noindex never came off           -> robots
ok   a url pattern moved                -> links
ok   an analytics tag stopped firing    -> tags
ok   a title is rewritten in the layout -> title
ok   the live url stops answering       -> status
ok   a path redirects to itself         -> BROKEN

8 of 8 failures caught

And the parsing underneath it has its own suite, over held bytes rather than over a network, covering the cases that are easy to get subtly wrong: an h1 read through inline markup, the same canonical written in two different hosts, a GA4 id inside a script body, a link to a third host not counted as internal, a link written by JavaScript not counted at all, an X-Robots-Tag read as robots, and the four readings of whether a chain of redirects has come back to a URL it already asked for.

ok   title flattened
ok   h1 text read through inline markup
ok   has_h1 yes on the real heading
ok   has_h1 no once it is a styled div
ok   h1 text gone with it
ok   canonical host folded to SITE
ok   canonical equal across the two hosts
ok   ga4 id found inside a script
ok   internal link kept as a path
ok   third-host link not counted internal
ok   link written by script not counted
ok   robots merged and lower-cased
ok   a chain that returns is a loop
ok   a self-redirect is a loop at the first hop
ok   a long chain that never returns is not a loop
ok   a trailing slash is a different URL, not a loop
ok   X-Robots-Tag read as robots

17 of 17

Both of those are in the repository, not described in it. The seven-failure test needs a copy of a built site to break, so it ships with the instructions to point it at your own.

The file you are downloading

Published here so the download is checkable rather than trusted. Both figures are read off the file served at /tools/release-diff.py when this page is built, so they cannot disagree with it.

PropertyValue
Filerelease-diff.py
Size20,335 bytes
SHA-256 8986553f2d5cb159b5ef8ee31a1b5cb6d51767f6e35d2458abd9db04d62959f6
Licence MIT — LICENSE.txt
Source github.com/UsamaIqbal0304/release-diff

To check it, on Linux sha256sum release-diff.py, on macOS shasum -a 256 release-diff.py, on Windows certutil -hashfile release-diff.py SHA256. A different digest means a different file — not necessarily a hostile one, but not this one.

The repository holds the same file, byte for byte, together with everything needed to re-run the checks this page's claims rest on — so they can be run rather than read about. Issues and pull requests there are read.

Also free

The others

Same idea, a different protocol or a different file. Every free tool.

Next step

Send the run that surprised you.

A report with one line in it you cannot explain is the useful kind. Send the output and the ticket it was supposed to match, and you will get back a reading of which of the two is wrong. That costs nothing either way.