commit 475d44579b70c5f56ca7fae9123eb43719d577bf
parent 11880bf4ad5f484ce4e65544b277b5e05652a5f1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 21:05:27 -0400
common: a compose over a stale index lists no withheld channel in the served posts and summaries manifests; buildHub and the deploy-all comments say what runs first (review LOW 3, NIT 7)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 71 insertions(+), 6 deletions(-)
diff --git a/common/bin/compose-site.postsVisibility.test.ts b/common/bin/compose-site.postsVisibility.test.ts
@@ -277,6 +277,22 @@ test("a config compose cannot read does not ship the posts tree the index build
}
});
+test("a compose over an index built before the setting flipped lists no X channel anywhere", async () => {
+ // Index with X public, then flip to private and compose WITHOUT indexing.
+ writeSettings("public");
+ await index();
+ writeSettings("private");
+ const pub = await compose("pub");
+ assert.deepEqual(pub.postTrees, [SKY]);
+ assert.deepEqual(slugs(pub.postsManifest.channels), [SKY]);
+ assert.equal(pub.postsManifest.totalCount, 1);
+ assert.equal(pub.corpus.channels.find((c) => c.slug === X)?.postCount, undefined);
+ assert.equal(pub.corpus.postScheme !== undefined, true, "the Bluesky posts are still advertised");
+ // The channel list too: no X channel in site.json or corpus.json.
+ assert.deepEqual(slugs(pub.siteJson.channels), [SKY, VIDEOS]);
+ assert.deepEqual(slugs(pub.corpus.channels), [SKY, VIDEOS]);
+});
+
test("X public again: the next build puts X back on the public site", async () => {
writeSettings("public");
await index();
diff --git a/common/bin/compose-site.ts b/common/bin/compose-site.ts
@@ -495,6 +495,23 @@ async function replaceDir(src: string, dest: string): Promise<void> {
}
}
+// Rewrite a served manifest whose `channels` name a slug outside `members`,
+// keeping the rest of it. A missing or unreadable manifest is left alone.
+// Answers whether it rewrote the file.
+async function narrowManifestChannels(file: string, members: Set<string>): Promise<boolean> {
+ let m: { channels?: { slug?: string }[] };
+ try {
+ m = JSON.parse(await readFile(file, "utf8"));
+ } catch {
+ return false;
+ }
+ const channels = m.channels ?? [];
+ const kept = channels.filter((c) => typeof c.slug !== "string" || members.has(c.slug));
+ if (kept.length === channels.length) return false;
+ await writePublicFile(file, JSON.stringify({ ...m, channels: kept }));
+ return true;
+}
+
// The channel slugs a site posts manifest lists, or null when there is no
// readable manifest.
async function readPostsManifestSlugs(file: string): Promise<Set<string> | null> {
@@ -751,6 +768,19 @@ export async function main(
} else {
console.log("[compose] summaries: unchanged.");
}
+ // The served summaries manifest lists only the members this compose
+ // publishes (lib/postsVisibility.ts), even over an index built before
+ // `social.x.visibility` flipped: site.json and corpus.json are built from it.
+ // A narrowed copy is no longer the source's copy: the next compose copies
+ // the summaries again rather than trusting it.
+ if (
+ await narrowManifestChannels(
+ path.join(paths.exportSummariesDir, "manifest.json"),
+ new Set(memberSlugs),
+ )
+ ) {
+ cache.summaries = undefined;
+ }
const statsSrc = path.join(paths.exportSitesIndexDir, siteId, "stats");
const statsSig = await dirSignature(statsSrc);
if (cache.stats !== statsSig || !(await exists(paths.exportStatsDir))) {
@@ -829,7 +859,21 @@ export async function main(
);
if (await exists(postsManifestSrc)) {
await ownDir(paths.exportPostsDir);
- await copyPublicFile(postsManifestSrc, path.join(paths.exportPostsDir, "manifest.json"));
+ // Narrowed to the members this compose publishes, so a compose run over an
+ // index built before `social.x.visibility` flipped (`archilyzer compose
+ // site` alone, `build site --nodata`) does not list a withheld X channel's
+ // name and count beside the pruned tree.
+ const pm = JSON.parse(await readFile(postsManifestSrc, "utf8")) as PostsManifest;
+ const members = new Set(memberSlugs);
+ const channels = (pm.channels ?? []).filter((c) => members.has(c.slug));
+ await writePublicFile(
+ path.join(paths.exportPostsDir, "manifest.json"),
+ JSON.stringify({
+ ...pm,
+ channels,
+ totalCount: channels.reduce((n, c) => n + (c.postCount ?? 0), 0),
+ }),
+ );
}
// Same for the per-site digests manifest (which channels carry digests).
const digestsManifestSrc = path.join(
diff --git a/common/publish/build.ts b/common/publish/build.ts
@@ -621,12 +621,15 @@ export async function runDockerDeployAllPhase(
outcomes.push({ siteId: site.siteId, status: "skipped", reason: "build failed" });
continue;
}
- // The bundle must be this site's own before anything else is asked of it —
- // the check build-site.sh makes before it hands the bundle back, made again
- // over whatever the per-site dir holds now. First, so that nothing past it
- // (the R2 upload, the Pages deploy) is ever reached with another site's data.
// A private site (or a private build) is not deployed at all: skipped, in
// its own words, so a family with one private site does not fail every run.
+ // Asked first; a per-site dir holding another site's bundle built private
+ // therefore reads "skipped" rather than "REFUSED" — never shipped either way.
+ //
+ // Then the bundle must be this site's own — the check build-site.sh makes
+ // before it hands the bundle back, made again over whatever the per-site dir
+ // holds now — before anything past it (the R2 upload, the Pages deploy) is
+ // reached with another site's data.
const audienceProblem = deployAudienceProblem(site, outDirFor(site.siteId));
if (audienceProblem) {
onLog(`[${site.siteId}] deploy skipped — ${audienceProblem}`);
@@ -923,7 +926,9 @@ export async function composeHub(opts: PublishOpts = {}): Promise<number> {
/**
* Build the hub into export/out. Removes public/site.json first — a site's
* compose left it there, and a hub bundle carrying one would read as that
- * site's (builtExport.ts). Returns the exit code.
+ * site's (builtExport.ts). compose-hub then removes every other per-site entry
+ * a site's compose left in public/ (SITE_ONLY_PUBLIC_ENTRIES), so the hub
+ * never ships a site's data. Returns the exit code.
*/
export async function buildHub(opts: PublishOpts = {}): Promise<number> {
const { paths, onLog, signal } = resolved(opts);