diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index efb51e3..f539aa3 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -188,6 +188,23 @@ if changed '^(resources/(js|css|data)/|vite\.config\.|package(-lock)?\.json|tsco
FRONTEND_CHANGED=true
fi
+# The pattern above classifies a diff. This asks the far simpler question the
+# diff is only a proxy for: is what we built older than what we built it from?
+#
+# The two disagree whenever a release is skipped, and a skipped rebuild does not
+# retry itself — the next deploy diffs against the commit that skipped it, sees
+# nothing front-end in that range, and leaves the stale bundle in place forever.
+# One misclassified path therefore strands the site until somebody runs FORCE=1.
+# It happened: data corrections deployed green and never reached the page, and
+# the deploy that fixed the classifier could not undo its own backlog.
+#
+# Comparing artifacts to sources costs one `find` and cannot be fooled by a
+# pattern nobody updated.
+if [ "$FRONTEND_CHANGED" = false ] && bundles_are_stale; then
+ echo " the built bundles are older than the sources — rebuilding regardless of the diff"
+ FRONTEND_CHANGED=true
+fi
+
# Kept apart from PHP_CHANGED below: a Blade edit needs the caches rebuilt and
# the bytecode dropped, but there is nothing new to download for it.
if changed '^composer\.(json|lock)$'; then
diff --git a/tests/Feature/DeployScriptTest.php b/tests/Feature/DeployScriptTest.php
index 7eab054..4fa2940 100644
--- a/tests/Feature/DeployScriptTest.php
+++ b/tests/Feature/DeployScriptTest.php
@@ -290,3 +290,37 @@ function deployMatches(string $flag, string $path): bool
expect(strpos($script, 'no server-rendered
'))
->toBeLessThan(strpos($script, 'serving a different build'));
});
+
+it('rebuilds when the artifacts are older than the sources, whatever the diff says', function (): void {
+ /*
+ * The changed-paths pattern classifies a diff. This asks the question the
+ * diff is only a proxy for: is what we built older than what we built it
+ * from?
+ *
+ * They disagree whenever a release is skipped, and a skipped rebuild does
+ * not retry itself — the next deploy diffs against the commit that skipped
+ * it, sees nothing front-end in that range, and leaves the stale bundle in
+ * place indefinitely. One misclassified path strands the site until someone
+ * runs FORCE=1 by hand.
+ *
+ * That is not hypothetical. `resources/data/*.json` was classified as
+ * content, so corrected prices deployed green and never reached the page —
+ * and the deploy that fixed the classifier could not undo its own backlog,
+ * because by then the data change was behind it.
+ */
+ $script = file_get_contents(base_path('scripts/deploy.sh'));
+
+ // The staleness check must be consulted for the front-end decision, not
+ // only inside the unchanged-commit branch.
+ expect(substr_count($script, 'bundles_are_stale'))->toBeGreaterThanOrEqual(
+ 3,
+ 'bundles_are_stale should be defined and consulted in both the skip branch and the front-end decision',
+ );
+
+ expect($script)->toMatch('/FRONTEND_CHANGED"?\s*=\s*false.*bundles_are_stale/s');
+
+ // And it has to be defined before both uses, or the shell sees an empty command.
+ $definedAt = strpos($script, 'bundles_are_stale() {');
+ expect($definedAt)->not->toBeFalse();
+ expect($definedAt)->toBeLessThan(strrpos($script, 'bundles_are_stale;'));
+});