commit 2d870074b807ee717c0502ac5f636674ac42972a
parent b568edb817b2a4c2fa658dd624cb0786cbb5dda8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 01:38:11 -0400
common: builtHomepageProblem + builtHomepageAt beside builtHubProblem, and homepageOutDir — homepage/out, the one path the editor's homepage actions judge and deployHomepage ships
builtHomepageProblem(outDir) refuses an out dir with no index.html, the file
deployHomepage checks inside its job; builtHomepageAt is that file's mtime,
for "built <when>" on /sites. homepageOutDir(paths) is homepage/out of the
checkout, exported so the editor does not re-derive it. buildHomepage and
deployHomepage are unchanged. +3 unit tests.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 97 insertions(+), 3 deletions(-)
diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts
@@ -1,9 +1,15 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { mkdtempSync, mkdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
-import { builtHubProblem, builtSiteIdIn, builtSiteProblem } from "./builtExport";
+import {
+ builtHomepageAt,
+ builtHomepageProblem,
+ builtHubProblem,
+ builtSiteIdIn,
+ builtSiteProblem,
+} from "./builtExport";
function tempOut(siteJson?: string): { dir: string; cleanup: () => void } {
const dir = mkdtempSync(path.join(tmpdir(), "built-export-"));
@@ -113,3 +119,44 @@ test("builtHubProblem accepts only a hub bundle", () => {
none.cleanup();
}
});
+
+// The homepage package builds into its own homepage/out, which nothing else
+// writes, so "built" is one question: is there an index.html? The deploy-only
+// action refuses before any job on exactly the file deployHomepage checks
+// inside it.
+test("builtHomepageProblem refuses an out dir with no index.html, and only that", () => {
+ const built = tempOut();
+ const empty = tempOut();
+ try {
+ writeFileSync(path.join(built.dir, "index.html"), "<!doctype html>");
+ assert.equal(builtHomepageProblem(built.dir), null);
+
+ const problem = "homepage/out holds no build — build the homepage first";
+ assert.equal(builtHomepageProblem(empty.dir), problem);
+ // No directory at all is the same answer, not a throw.
+ assert.equal(builtHomepageProblem(path.join(tmpdir(), "no-such-homepage-out-ever")), problem);
+ // A stray file that is not the page is still no build.
+ writeFileSync(path.join(empty.dir, "404.html"), "<!doctype html>");
+ assert.equal(builtHomepageProblem(empty.dir), problem);
+ } finally {
+ built.cleanup();
+ empty.cleanup();
+ }
+});
+
+test("builtHomepageAt is index.html's mtime, and null with no build", () => {
+ const built = tempOut();
+ const empty = tempOut();
+ try {
+ const index = path.join(built.dir, "index.html");
+ writeFileSync(index, "<!doctype html>");
+ const when = new Date("2026-09-28T00:53:00Z");
+ utimesSync(index, when, when);
+ assert.equal(builtHomepageAt(built.dir), when.getTime());
+ assert.equal(builtHomepageAt(empty.dir), null);
+ assert.equal(builtHomepageAt(path.join(tmpdir(), "no-such-homepage-out-ever")), null);
+ } finally {
+ built.cleanup();
+ empty.cleanup();
+ }
+});
diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts
@@ -13,7 +13,7 @@
// public/ into out/. So the check is a file read, and it is cheap enough to do
// before every deploy.
-import { existsSync, readFileSync } from "node:fs";
+import { existsSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
/**
@@ -80,3 +80,33 @@ export function builtHubProblem(outDir: string): string | null {
}
return null;
}
+
+/**
+ * Why `outDir` — the homepage package's `homepage/out` — may not be deployed as
+ * the homepage, as one sentence, or null when it holds a build.
+ *
+ * Unlike export/out nothing else ever builds into homepage/out, so the only
+ * question is whether a build is there at all. `index.html` is the file
+ * deployHomepage checks inside its job, so the refusal an operator gets before
+ * the job and the one the job would give agree on what "built" means.
+ */
+export function builtHomepageProblem(outDir: string): string | null {
+ if (!existsSync(path.join(outDir, "index.html"))) {
+ return "homepage/out holds no build — build the homepage first";
+ }
+ return null;
+}
+
+/**
+ * When the homepage build in `outDir` was made (its `index.html`'s mtime, in
+ * ms), or null when builtHomepageProblem would refuse it. What `/sites` shows
+ * as "built <when>" beside Deploy homepage, so the operator sees how old the
+ * bundle a deploy-only would ship is.
+ */
+export function builtHomepageAt(outDir: string): number | null {
+ try {
+ return statSync(path.join(outDir, "index.html")).mtimeMs;
+ } catch {
+ return null;
+ }
+}
diff --git a/common/publish/build.test.ts b/common/publish/build.test.ts
@@ -7,6 +7,7 @@ import {
buildSiteSteps,
hubProjectProblem,
homepageDeployArgs,
+ homepageOutDir,
deployHomepage,
dockerSiteOutDir,
dockerSiteStagingDir,
@@ -136,6 +137,12 @@ test("homepageDeployArgs: production is branch main; a preview is its own branch
assert.deepEqual(homepageDeployArgs("/repo/homepage/out", " r8-home "), [...base, "--branch", "r8-home"]);
});
+// The editor's homepage actions judge "is there a build?" on this path before
+// any job, so it must be the directory deployHomepage ships.
+test("homepageOutDir is homepage/out of the checkout", () => {
+ assert.equal(homepageOutDir({ monorepoRoot: "/repo" } as Paths), "/repo/homepage/out");
+});
+
test("deployHomepage refuses a bad preview branch before it looks for a build", async () => {
const noBuild = { monorepoRoot: "/nonexistent-repo" } as Paths;
await assert.rejects(
diff --git a/common/publish/build.ts b/common/publish/build.ts
@@ -939,6 +939,16 @@ function homepageDir(paths: Paths): string {
return path.join(paths.monorepoRoot, "homepage");
}
+/**
+ * Where buildHomepage writes and deployHomepage ships: `homepage/out` of this
+ * checkout. Exported for the editor's homepage actions, which refuse a
+ * deploy-only before any job when nothing is built there
+ * (builtHomepageProblem) and show when it was built.
+ */
+export function homepageOutDir(paths: Paths): string {
+ return path.join(homepageDir(paths), "out");
+}
+
function homepageEnv(paths: Paths): NodeJS.ProcessEnv {
return {
...process.env,