commit b7f718a268278aa1a525cc5dbe136201e4cee501
parent bd5081a5a0396b60da4e2c3d69d7462cfd38e829
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 12:15:56 -0400
publish: the index is judged by the settings it reads (a signature in the stamp), not the settings file's mtime
Every pause click, priority change and drive auto-pause writes settings.json,
and each made the index "stale" — one short-circuited update per refresh
interval. `indexSettingsSig` (inputSig.ts) signs the keys the index and the
builds it signs read (social links, homepage url, buildArchives,
archiveStorage, social.x.visibility, the transcript page size, the storage
locations' roots); update-index records it as `settingsSig`, and needs() says
"the settings the index reads changed" when it differs. The charts config
stays an mtime (its own file).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
9 files changed, 120 insertions(+), 6 deletions(-)
diff --git a/common/publish/inputSig.ts b/common/publish/inputSig.ts
@@ -145,6 +145,44 @@ export async function siteInputSig(i: SiteSigInputs): Promise<string> {
return sha1(lines.join("\n"));
}
+/** The settings the index stage reads (see indexSettingsSig). */
+export type IndexSettingsInputs = Pick<
+ SiteSettings,
+ | "socialLinks"
+ | "homepageUrl"
+ | "buildArchives"
+ | "archiveStorage"
+ | "social"
+ | "maxTranscriptPageBytes"
+ | "storage"
+>;
+
+/**
+ * sha1 over the settings keys the index stage and the builds it signs read —
+ * NOT the settings file's mtime, which every pause click, priority change and
+ * drive auto-pause moves (release 18 S4 step 1). The keys: the social links and
+ * the homepage url (the builds render them), `buildArchives`, `archiveStorage`,
+ * `social.x.visibility` (which members a site publishes), the transcript page
+ * size (the index shards by it) and the storage locations' roots (which
+ * channels the index can reach). A superset is conservative: a short-circuited
+ * index update, never a missed one. The charts config is its own file, judged
+ * by its mtime.
+ */
+export function indexSettingsSig(settings: Partial<IndexSettingsInputs>): string {
+ return sha1(
+ JSON.stringify({
+ v: 1,
+ socialLinks: settings.socialLinks ?? null,
+ homepageUrl: settings.homepageUrl ?? null,
+ buildArchives: settings.buildArchives ?? null,
+ archiveStorage: settings.archiveStorage ?? null,
+ xVisibility: settings.social?.x?.visibility ?? null,
+ maxTranscriptPageBytes: settings.maxTranscriptPageBytes ?? null,
+ locations: (settings.storage?.locations ?? []).map((l) => [l.id, l.root]),
+ }),
+ );
+}
+
/** Every member's channel config, read once (unreadable = null). */
export async function readMemberConfigs(
paths: Paths,
diff --git a/common/publish/publishPlan.ts b/common/publish/publishPlan.ts
@@ -129,6 +129,8 @@ export type PublishInputs = {
lastIngestDoneAt: number | null;
// The newest mtime of an index input config file, or null.
configChangedAt: number | null;
+ // indexSettingsSig of the settings now (stages.ts NeedsInput).
+ settingsSig?: string;
};
// Per channel: when its newest ingest ended (a job meta, or its report's
// regeneration — see publishState.ts).
diff --git a/common/publish/publishState.test.ts b/common/publish/publishState.test.ts
@@ -28,6 +28,10 @@ process.env.CURATED_TAGS_FILE = path.join(ROOT, "transcripts", "tags.json");
const { getPaths } = await import("../lib/paths");
const { readPublishInputs, readPublishStatus, resetPublishStateCache } = await import("./publishState");
const { stageSpec } = await import("./publishStages");
+const { indexSettingsSig } = await import("./inputSig");
+const { writeIndexStamp } = await import("./stamps");
+const { indexStamp } = await import("./__fixtures__/stamps");
+const { getSettings } = await import("../lib/settings");
type JobRecord = import("../jobs/registry").JobRecord;
after(() => rm(ROOT, { recursive: true, force: true }));
@@ -163,3 +167,30 @@ test("readPublishStatus: the chips a fresh scratch corpus shows", async () => {
assert.equal(s.lane.enabled, true);
assert.equal(s.lane.due, true, "no stamp: a pass is due");
});
+
+test("the index is stale on the settings it reads, not on a settings-file write", async () => {
+ setup();
+ resetPublishStateCache();
+ // A stamp newer than every input, signed over the settings as they are.
+ await writeIndexStamp(paths, indexStamp({ scannedAt: T + 1, builtAt: T + 1, settingsSig: indexSettingsSig(getSettings()) }));
+ const fresh = async () => (await readPublishStatus(paths, { now: T + 2, live: [], laneKnown: false })).index;
+ assert.equal((await fresh()).fresh, true, "signed over these settings");
+
+ // A priority change (a pause click, a drive auto-pause) writes the file NOW.
+ writeJson(paths.settingsFile, {
+ publish: { enabled: true, hub: "build", previewBranch: "smoke" },
+ channelPriority: { channels: { "a-one": { tier: "high" } } },
+ });
+ resetPublishStateCache();
+ assert.equal((await fresh()).fresh, true, "the file is newer than the stamp; what the index reads is not");
+
+ // A social link is rendered by every build: the index is stale.
+ writeJson(paths.settingsFile, {
+ publish: { enabled: true, hub: "build", previewBranch: "smoke" },
+ socialLinks: [{ label: "Site", url: "https://example.com", svg: '<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>' }],
+ });
+ resetPublishStateCache();
+ const after = await fresh();
+ assert.equal(after.fresh, false);
+ assert.deepEqual(after.freshness, { state: "stale", reason: "the settings the index reads changed" });
+});
diff --git a/common/publish/publishState.ts b/common/publish/publishState.ts
@@ -7,7 +7,9 @@
// hub's and the homepage's (settings.publish);
// - the input files' mtimes the plan lists: for the index tags.json,
// search-aliases.json, duplicates*.json, every sites/*/site.json,
-// homepage.json, the settings file and the charts config; for a site its
+// homepage.json and the charts config — the SETTINGS by the keys the index
+// reads (inputSig.ts indexSettingsSig), never the file's mtime, which
+// every pause click moves; for a site its
// site.json, its tags and aliases and the corpus-wide three; for the hub
// homepage.json and every site.json; for the homepage homepage.json;
// - the ingest signal, per channel: every job meta of an ingest kind
@@ -37,6 +39,7 @@ import { getHomepageConfig } from "../lib/homepage";
import { getPaths, type Paths } from "../lib/paths";
import { PROJECT_URL } from "../lib/project";
import { getSettings, normalizeHomepageUrl } from "../lib/settings";
+import { indexSettingsSig } from "./inputSig";
import {
isListedSite,
isPrivateSite,
@@ -324,9 +327,11 @@ export async function readPublishInputs(
corpusWide,
...siteJsonAt.values(),
homepageJsonAt,
- await mtimeOf(paths.settingsFile),
await mtimeOf(paths.chartsConfigFile),
);
+ // The settings are judged by the keys the index reads, not the file's mtime
+ // (every pause click writes the file).
+ const settingsSig = indexSettingsSig(settings);
const siteInputs: PublishSiteInput[] = [];
for (const s of sites) {
@@ -357,7 +362,7 @@ export async function readPublishInputs(
const mainHead = await memoized("mainHead", now, () => mainHeadOf(paths));
const commit = await memoized("commit", now, async () => (await checkoutInfo(paths)).commit);
return {
- index: { stamp: needs.index.stamp, lastIngestDoneAt, configChangedAt: indexConfigAt },
+ index: { stamp: needs.index.stamp, lastIngestDoneAt, configChangedAt: indexConfigAt, settingsSig },
ingestByChannel: byChannel,
commit,
sites: siteInputs,
diff --git a/common/publish/stageBodies.ts b/common/publish/stageBodies.ts
@@ -229,7 +229,7 @@ async function runUpdateIndex(ctx: StageContext): Promise<StageOutcome> {
const { buildIndex } = await import("../controller/buildIndex");
const { buildStats } = await import("../controller/buildStats");
const { syncTemplatesToExport } = await import("../lib/chartsStore");
- const { hubInputSig, readIndexMeta, readMemberConfigs, siteInputSig } = await import("./inputSig");
+ const { hubInputSig, indexSettingsSig, readIndexMeta, readMemberConfigs, siteInputSig } = await import("./inputSig");
// A CLI process has no health pass: the drive-health timings the builds'
// watchdog runs on are applied here, once (bin/build-index.ts does the same).
@@ -311,6 +311,7 @@ async function runUpdateIndex(ctx: StageContext): Promise<StageOutcome> {
},
sites: siteEntries,
hubSig: await hubInputSig(paths, stampId, sites),
+ settingsSig: indexSettingsSig(settings),
};
const { writeIndexStamp } = await import("./stamps");
await writeIndexStamp(paths, stamp);
diff --git a/common/publish/stages.test.ts b/common/publish/stages.test.ts
@@ -100,6 +100,29 @@ test("update-index: fresh when a stamp exists and nothing is newer than its scan
assert.equal(reason(needs(input(), req("update-index", "_index", { force: true }))), "forced");
});
+test("update-index: the settings it reads, by signature — not judged when the caller read none", () => {
+ const signed = (stampSig: string | undefined, nowSig: string | undefined) =>
+ input({
+ index: {
+ stamp: indexStamp(stampSig === undefined ? {} : { settingsSig: stampSig }),
+ lastIngestDoneAt: null,
+ configChangedAt: null,
+ ...(nowSig === undefined ? {} : { settingsSig: nowSig }),
+ },
+ });
+ assert.equal(state(needs(signed("a", "a"), req("update-index", "_index"))), "fresh");
+ assert.equal(
+ reason(needs(signed("a", "b"), req("update-index", "_index"))),
+ "the settings the index reads changed",
+ );
+ assert.equal(
+ reason(needs(signed(undefined, "b"), req("update-index", "_index"))),
+ "the settings the index reads changed",
+ "a stamp from before the signature reads as changed",
+ );
+ assert.equal(state(needs(signed("a", undefined), req("update-index", "_index"))), "fresh");
+});
+
// --- build-site <id> ----------------------------------------------------------
test("build-site: blocked 'update the index first' with no stamp — even forced", () => {
diff --git a/common/publish/stages.ts b/common/publish/stages.ts
@@ -125,8 +125,11 @@ export type NeedsInput = {
lastIngestDoneAt: number | null;
// The newest mtime (ms) of an index input config file (tags.json,
// search-aliases.json, duplicates*.json, sites/*/site.json,
- // homepage.json, the settings file, the charts config), or null.
+ // homepage.json, the charts config), or null.
configChangedAt: number | null;
+ // indexSettingsSig over the settings as they are now; compared with the
+ // stamp's. Absent: not judged (a caller that did not read settings).
+ settingsSig?: string;
};
sites: Record<string, TargetState>;
hub: TargetState;
@@ -167,6 +170,9 @@ function needsIndex(s: NeedsInput, r: StageRequest): Freshness {
if (s.index.configChangedAt !== null && s.index.configChangedAt > stamp.scannedAt) {
return stale("a config file changed since the last index");
}
+ if (s.index.settingsSig !== undefined && s.index.settingsSig !== stamp.settingsSig) {
+ return stale("the settings the index reads changed");
+ }
return FRESH;
}
diff --git a/common/publish/stamps.ts b/common/publish/stamps.ts
@@ -48,6 +48,9 @@ export type IndexStamp = {
// (null when absent), and the inputSig compose's skip rule is computed from.
sites: Record<string, { siteFp: string | null; statsFp: string | null; inputSig: string }>;
hubSig: string;
+ // inputSig.ts indexSettingsSig over the settings the index read (absent on
+ // a stamp written before release 18's surfaces: it reads as changed).
+ settingsSig?: string;
};
export type BuiltKind = "site" | "hub" | "homepage";
@@ -169,6 +172,7 @@ export function asIndexStamp(v: unknown): IndexStamp | null {
return null;
}
if (!isObj(v.sites) || !isStr(v.hubSig)) return null;
+ if (v.settingsSig !== undefined && !isStr(v.settingsSig)) return null;
for (const s of Object.values(v.sites)) {
if (!isObj(s) || !isStr(s.inputSig) || !isStrOrNull(s.siteFp) || !isStrOrNull(s.statsFp)) return null;
}
diff --git a/plans/release-18.md b/plans/release-18.md
@@ -132,7 +132,11 @@ Probe = { status|null; generatedAt?; cfCacheStatus?; age?; cacheControl?; error?
(download/transcribe/digest/normalize/import/fetch-posts/tag kinds — the drainable kinds of `jobKinds.ts`) that ENDED
`done` after `stamp.scannedAt` — read through the registry's archive reader, no new writer anywhere; (2) a config file
newer than the stamp: `tags.json`, `search-aliases.json`, `duplicates*.json`, `sites/*/site.json`, `homepage.json`, the
- settings file, the charts config. Decided: mtimes/job completions decide WHEN to run, the index child decides WHAT
+ charts config; (3) **the settings the index reads, by signature, not the settings file's mtime** (S4 step 1, after S3
+ found every pause click, priority change and drive auto-pause staling the index): `indexSettingsSig` (`inputSig.ts`)
+ over `socialLinks`, `homepageUrl`, `buildArchives`, `archiveStorage`, `social.x.visibility`,
+ `maxTranscriptPageBytes` and the storage locations' roots, recorded as `settingsSig` in the index stamp and compared
+ by `needs()` ("the settings the index reads changed"; a stamp without one reads as changed, once). Decided: mtimes/job completions decide WHEN to run, the index child decides WHAT
changed (its fingerprints stat every sidecar and are the only correct detector; LMDB `generation` cannot see new files).
- **A site is marked stale by its channels, before any index runs** (operator, 2026-10-05): per site,
`changedChannels` = the member channels (by `site.json` membership, groups expanded) that have an ingest job meta