Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/bin/compose-site.postsVisibility.test.ts | 16++++++++++++++++
Mcommon/bin/compose-site.ts | 46+++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/publish/build.ts | 15++++++++++-----
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);