A site went three days without publishing anything, while every deploy printed its success
message and exited zero. Nothing was broken. The bare repo received each push, the hook ran, and it wrote
correct current files to disk. It wrote them to a machine that no longer served the domain. The live page's
last-modified header had been frozen since the day the site moved hosts.
Push-to-deploy is a chain, and each link only knows about the next one:
post-receive hook fires and checks the work tree out. Success.Nothing in steps one to four has any way to detect step five. The exit code is telling you the truth about a question you did not mean to ask.
One header answers it. Push something, then:
curl -sI https://example.com/ | grep -i last-modified
A timestamp older than your push means you published nothing, whatever the deploy said. This is the whole check, and it belongs at the end of every deploy you care about.
To find out where the content is actually coming from, ask each candidate host directly with an explicit
Host header, which bypasses DNS entirely:
# does this specific host serve current content for that name
curl -sI --resolve example.com:443:<host-ip> https://example.com/ | grep -i last-modified
# and where does the world think the domain lives
dig +short example.com
When one host returns your new content and the public URL does not, you have found the split.
To compare what is live against what is in the repo, list both and diff. There is a trap in the obvious version:
ssh live-host 'cd /srv/site && find . -type f' | LC_ALL=C sort > /tmp/live
ssh build-host 'cd /srv/site && find . -type f' | LC_ALL=C sort > /tmp/repo
LC_ALL=C comm -23 /tmp/live /tmp/repo # live but not in the repo
comm requires input sorted in the same collation it compares with. Sort in one locale and
compare in another and it reports nonsense, typically listing the same files as unique to both
sides, which reads like catastrophic drift and is an artefact. Force LC_ALL=C on the sorts and
on comm itself.
Once you do wire up a real sync, rsync --delete is how the destination stops drifting. It is
also how you lose files nobody realised were only ever on the live host.
The diff above found a Search Console verification file that existed on the live host and in the serving
docroot, but was in version control nowhere. It survived by accident. A clean re-checkout
would have removed it from the source, and the next --delete sync would have silently
un-verified the domain.
# anything listed here is live content a --delete would drop
git status --porcelain --untracked-files=all | grep '^??'
Run that in the serving docroot before trusting any sync. Then commit what should be tracked, and explicitly exclude what the destination legitimately owns. Generated status files and anything written by a process on the live host belong in the exclude list, not in the repo.
--delete, turns a broken
build into an empty website. A one-line sanity check costs nothing and removes the worst outcome.
The durable fix is that the pipeline verifies its own claim. Put the public-URL check in the hook, after the sync, and fail loudly when it does not move:
before=$(curl -sI https://example.com/ | awk -F': ' '/[Ll]ast-[Mm]odified/{print $2}')
rsync -a --delete ...
sleep 2
after=$(curl -sI https://example.com/ | awk -F': ' '/[Ll]ast-[Mm]odified/{print $2}')
[ "$before" = "$after" ] && echo "WARNING: live site did not change" >&2
--delete to anything.