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
| Field | What it is |
|---|---|
status | The HTTP status after redirects. A page that stopped answering moves this field and nothing else. |
redirect | The 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. |
h1 | The first <h1>'s text, read through inline markup. |
has_h1 | Whether 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>. |
links | The 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. |
tags | Which 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. |
jsonld | The 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.
| Property | Value |
|---|---|
| File | release-diff.py |
| Size | 20,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.
bacnet-sweep
Broadcasts a BACnet/IP Who-Is, tables the devices that answer, and dumps a named device's object list — object name, present value and units — to a table or to CSV. It can encode two BACnet services and no others: Who-Is and ReadProperty.
- BACnet/IP
- Who-Is
- CSV out
mqtt-tap
Subscribes to a broker and prints what is actually on it: the topic tree with a count, a rate, a payload-type guess and the last value per topic, plus the retained topics that stopped updating. It sends five packet types and none of them is PUBLISH.
- MQTT
- Topic tree
- Retained
decoder-check
Runs a LoRaWAN device vendor's payload decoder against your frames in a sealed vm context and reports what a station would actually get back: crashes on a short frame, types that change between uplinks, units glued into values, keys a station has to escape. It reads frames and nothing else - no network, no broker, no network server.
- LoRaWAN
- Decoder
- Sandboxed
modbus-address-scan
Reads modbusCore-rt.jar out of a Niagara installation with javap and prints the register a point of each address format actually asks for — including the four band boundaries that all resolve to the same one.
- Modbus
- Shipped jars
- No station
ede-check
Reads the EDE import rules out of bacnetEDE-wb.jar with javap, then reports line by line what the shipped parser would reject in your point file and what it would silently default.
- EDE
- Point lists
- Line by line
module-sign-scan
Reads the verification code out of a Niagara installation and prints the four modes, the signature state each one accepts or refuses, and the exact log line a station writes — including the warning that only becomes a refusal when a certificate expires.
- Module signing
- Shipped jars
- No station
bacnet-priority-scan
Reads bacnet-rt.jar with javap and prints the object types Niagara writes through the priority array without asking, the ones it probes with a single ReadProperty, and what a failed probe does to the point for the life of that configuration.
- BACnet
- Shipped jars
- No station
alarm-route-scan
Reads alarm-rt.jar and baja.jar with javap and prints what happens to an alarm between the source and the recipient: one queue, one worker thread, the coalesce key that decides which duplicate is dropped, and why the invocation that lost that collision still reports success.
- Alarms
- Shipped jars
- No station
alarm-recipient-scan
Reads the recipient side of alarm-rt.jar with javap and prints why returning false from sendAlarm drops the alarm silently, what throwing does instead, how long the retry loop runs, and which four properties are the only evidence a site can send you.
- Alarms
- Retry
- No station
schedule-scan
Reads schedule-rt.jar with javap and prints the 90-day scanLimit horizon that turns a far-off change into no change at all, why nextCov steps over a boundary whose value matches, and the one serial uncapped queue every control schedule shares.
- Schedules
- Shipped jars
- No station
workbook-scan
Opens an .xlsx as the zip of XML it is and reports what is in the bytes: formulas saved holding an error, links into files that may be gone, saved queries to one person's mapped drive, approximate VLOOKUPs, hidden sheets, and rules nothing protects. It reads the old binary .xls too.
- Excel
- No install
- JSON out
poll-scheduler-scan
Reads the poll scheduler out of driver-rt.jar and prints the arithmetic: three rate defaults, one point polled per pass, and the bucket size at which the thread stops sleeping and the real cycle time stretches.
- Niagara
- javap
- Read-only
tuning-stale-scan
Reads the tuning policy and the stale branch out of the shipped jars: the default staleTime of zero, the clock it measures, and why an unsolicited device can sit dead with an ok status for as long as the station runs.
- Niagara
- javap
- Read-only
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.