diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index 48231a9..886c6a9 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -135,9 +135,32 @@ git pull --ff-only origin "$BRANCH"
CURR_COMMIT="$(git rev-parse HEAD)"
echo " now at $(git rev-parse --short HEAD) $(git log -1 --format=%s)"
+# The commit is a proxy for "the bundles are current", and it is wrong in the
+# one case that matters: someone repaired this checkout by hand. A `git reset
+# --hard` — which the runbook tells you to run after a force-push — moves the
+# sources without touching public/build or bootstrap/ssr, because both are
+# gitignored build output. The next deploy then sees an unchanged commit,
+# reports success, and leaves the site on bundles built from older sources.
+#
+# That happened: a deploy went green while the live homepage served the previous
+# release, and the smoke test passed because the stale bundle still renders a
+# perfectly valid page.
+bundles_are_stale() {
+ [ -f public/build/manifest.json ] || return 0
+ [ -f bootstrap/ssr/ssr.js ] || return 0
+
+ # Any front-end source newer than the manifest means the manifest predates it.
+ [ -n "$(find resources package.json package-lock.json vite.config.js tsconfig.json \
+ -newer public/build/manifest.json -print -quit 2>/dev/null)" ]
+}
+
# Only the work the diff actually calls for. A blog post is markdown read by PHP
# at request time, so publishing one needs no bundle; a component change does.
-if [ "$PREV_COMMIT" = "$CURR_COMMIT" ] && [ -z "$FORCE" ]; then
+if [ "$PREV_COMMIT" = "$CURR_COMMIT" ] && [ -z "$FORCE" ] && bundles_are_stale; then
+ echo " commit unchanged, but the built bundles are missing or older than the sources"
+ echo " rebuilding anyway — a hand-repaired checkout looks identical to an idle one"
+ CHANGED_FILES="$(git ls-files)"
+elif [ "$PREV_COMMIT" = "$CURR_COMMIT" ] && [ -z "$FORCE" ]; then
echo " already up to date — nothing to build (FORCE=1 to rebuild anyway)"
CHANGED_FILES=""
elif [ "$PREV_COMMIT" = "$CURR_COMMIT" ]; then
@@ -281,8 +304,28 @@ else
fail "$SMOKE_URL returned 200 but no server-rendered
— is SSR running?"
fi
+ # And that what it rendered is *this* release. An proves SSR is alive;
+ # it does not prove the bundle behind it is the one just built, and a stale
+ # bundle renders a completely valid previous version of the site. Compare
+ # the entry filename in the manifest against what the page actually loads —
+ # Vite hashes it per build, so they match only if the served app is this one.
+ BUILT_ENTRY="$(tr -d ' \n' < public/build/manifest.json \
+ | grep -o '"resources/js/app.tsx":{"file":"[^"]*"' \
+ | sed 's/.*"file":"//;s/"$//')"
+
+ if [ -z "$BUILT_ENTRY" ]; then
+ rm -f "$smoke_file"
+ fail "could not read the app entry out of public/build/manifest.json"
+ fi
+
+ if ! grep -q "$BUILT_ENTRY" "$smoke_file"; then
+ rm -f "$smoke_file"
+ printf ' expected asset: %s\n' "$BUILT_ENTRY" >&2
+ fail "$SMOKE_URL is serving a different build than the one just deployed"
+ fi
+
rm -f "$smoke_file"
- echo " $SMOKE_URL 200, server-rendered markup present"
+ echo " $SMOKE_URL 200, serving $BUILT_ENTRY"
fi
printf '\n\033[32mDeployed %s (was %s)\033[0m\n' \
diff --git a/tests/Feature/DeployScriptTest.php b/tests/Feature/DeployScriptTest.php
index 086c92b..f4dc567 100644
--- a/tests/Feature/DeployScriptTest.php
+++ b/tests/Feature/DeployScriptTest.php
@@ -212,3 +212,47 @@ function deployMatches(string $flag, string $path): bool
expect(strpos($code, 'git merge-base --is-ancestor HEAD'))
->toBeLessThan(strpos($code, 'git pull --ff-only'));
});
+
+it('does not trust the commit alone to mean the bundles are current', function (): void {
+ /*
+ * `public/build` and `bootstrap/ssr` are gitignored, so a `git reset --hard`
+ * — which the runbook prescribes after a force-push — moves the sources and
+ * leaves the built output untouched. The next deploy then sees an unchanged
+ * commit, skips the build, reports success, and leaves the site serving the
+ * previous release.
+ *
+ * That is not hypothetical: a deploy went green while the live homepage was
+ * still the pre-rewrite page, and the smoke test passed because a stale
+ * bundle renders a perfectly valid old site.
+ */
+ $script = file_get_contents(base_path('scripts/deploy.sh'));
+
+ expect($script)->toContain('bundles_are_stale');
+
+ // The skip branch must consult it, not just define it.
+ expect($script)->toMatch('/PREV_COMMIT.+CURR_COMMIT.+FORCE.+bundles_are_stale/s');
+
+ // And it has to look at both halves of the build, not only the assets.
+ expect($script)
+ ->toContain('public/build/manifest.json')
+ ->toContain('bootstrap/ssr/ssr.js');
+});
+
+it('proves the smoke test hit this release and not merely a live one', function (): void {
+ /*
+ * An `` proves SSR is alive. It does not prove the bundle behind it is
+ * the one just built — which is the exact failure above, where every check
+ * passed against the previous release.
+ *
+ * Vite hashes the entry filename per build, so the manifest's entry appears
+ * in the served HTML only when the served app is this build.
+ */
+ $script = file_get_contents(base_path('scripts/deploy.sh'));
+
+ expect($script)->toContain('BUILT_ENTRY');
+ expect($script)->toContain('serving a different build than the one just deployed');
+
+ // The assertion has to run after the check, inside the same block.
+ expect(strpos($script, 'no server-rendered '))
+ ->toBeLessThan(strpos($script, 'serving a different build'));
+});