// `archilyzer source publish` — the repo on the project site, read-only. // // The private repository is never rewritten. Every publish makes a FRESH bare // clone of its `main`, rewrites that copy with git-filter-repo (the operator's // scrub rules over file contents AND commit messages), repacks it for git's // dumb-HTTP protocol, and publishes these under homepage/public: // // source/archilyzer.git/ a clonable mirror: HEAD, refs, info/refs, // objects/info/packs, the packs — static files only // source/tree/ the tracked files of main, raw, with an // index.html per directory (sourceTree.ts) // source/git/ the history: the log, a page per commit with its // diff, the refs, two Atom feeds — stagit's, when // it is installed (sourceHistory.ts) // downloads/ archilyzer-source.tar.gz + snapshot.json, the // tarball the Downloads page has always offered // source/manifest.json written LAST: what was published, from what // // THE GATE. Before anything is staged for the site, every object of the // rewritten mirror, and then every staged file, is searched for every denied // literal (sourceAudit.ts): the operator's denylist plus every scrub rule's // left side. One hit and nothing is written; the report names the literal by // number, never by its bytes. // // OPERATOR-PRIVATE INPUTS live outside the repo, in // `${ARCHILYZER_CONFIG_DIR ?? ~/.config/archilyzer}/`: source-scrub.txt // (git-filter-repo `lhs==>rhs` lines; `==>/home/user` is always // applied first) and source-denylist.txt (one literal per line, `i:` = any // case). A missing file is a refusal naming it. Nothing here names a user. // // `buildHomepage` runs this between compose and `next build`, so `archilyzer // build homepage`, the runbooks' home scripts and the editor's /sites homepage // jobs all publish it; an unchanged main with unchanged rules skips. import { execFile } from "node:child_process"; import { createHash } from "node:crypto"; import { createReadStream, existsSync } from "node:fs"; import { cp, lstat, mkdir, mkdtemp, readdir, readFile, realpath, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { runChildIntoLog } from "../jobs/runChild"; import { copyPublicFile, ownDir, writePublicFile } from "../bin/_publicFile"; import { getPaths, type Paths } from "../lib/paths"; import { PROJECT_NAME, PROJECT_URL } from "../lib/project"; import { PUBLISH_MAX_FILE_BYTES } from "../lib/builtExport"; import { CLONE_URL, HISTORY_DIR, HISTORY_LOG_HREF, MIRROR_DIR, SOURCE_MANIFEST_VERSION, TARBALL_HREF, TREE_HREF, parseSourceManifest, type SourceHistory, type SourceManifest, } from "../lib/sourceManifest"; import { HistoryProblem, STAGIT_INSTALL, dropHistoryCache, SOURCE_HISTORY_MAX_COMMITS, historyCacheKey, historyStylesheet, homepageThemeScript, renderHistory, resolveStagit, stagitIdentity, type HistoryRun, } from "./sourceHistory"; import { SourceRefusal, auditBare, auditFiles, cleanGitEnv, dedupeLiterals, formatAuditReport, operatorLines, maskLiterals, onPath, parseDenylist, tildify, type Literal, } from "./sourceAudit"; import { writeTreeIndexes } from "./sourceTree"; // Type-only: build.ts imports THIS module lazily, and must not load it (or the // AWS SDK this would drag in) at import time. import type { PublishOpts } from "./build"; export { SourceRefusal } from "./sourceAudit"; /** The one branch mirrored (an operator decision: no other refs, no tags). */ export const SOURCE_BRANCH = "main"; /** * The step's own version, hashed into the rules hash (the skip key and the * deploy check). BUMP IT WHENEVER THE SCRUB OR THE AUDIT CHANGES — what the * rules parse to, what the gate reads, what it refuses — so an unchanged * `main` is re-published through the fixed step instead of skipped, and a * `homepage/out` built by the old step refuses to deploy. * 1 — release 12 slice R. * 2 — release 12 slice R review: BOM/CRLF/trailing-slash parsing, empty * left sides refused, `404.html` refused, no context bytes. * 3 — the re-review: masked refusal messages, a digest of every published * file and the gitleaks identity in the key. * 4 — release 15 slice SG: the history pages (source/git/, stagit) are * staged, swept by the file gate and bound by the digest; stagit's * identity is in the key. */ export const SOURCE_STEP_VERSION = 4; /** What `pipx run` fetches when `git filter-repo` is not installed. */ export const FILTER_REPO_PIPX_SPEC = "git-filter-repo==2.47.0"; export const FILTER_REPO_INSTALL = "pipx install git-filter-repo"; /** The home-directory rule's replacement (always the first scrub rule). */ export const HOME_REPLACEMENT = "/home/user"; // Cloudflare Pages allows 20,000 files per deployment and 25 MiB per file; // the step refuses well inside both, leaving the rest of the site its room. export const MAX_FILES = 15_000; export const MAX_FILE_BYTES = PUBLISH_MAX_FILE_BYTES; // Packs are split at this size (under the per-file cap, with room to grow). const PACK_SIZE = "20m"; const TARBALL_NAME = path.basename(TARBALL_HREF); export type SourcePublishOpts = PublishOpts & { // Rebuild even when main and the rules are unchanged. force?: boolean; // Build, audit and count everything, then write NOTHING. check?: boolean; // Leave the scratch dir (the rewritten bare clone, the stage) for a look. keepScratch?: boolean; // The repository to mirror. Default: ARCHILYZER_SOURCE_REPO (a container's // mount of the host's repository), else this checkout's git COMMON dir, so a // worktree build mirrors the primary's main. sourceRepo?: string; // Default: HOMEPAGE_PUBLIC_DIR, else /homepage/public. publicDir?: string; scrubFile?: string; denylistFile?: string; // The filter-repo argv; default: resolveFilterRepo(). filterRepo?: string[] | null; // The gitleaks binary; null skips the secret scan (tests). Default "gitleaks". gitleaks?: string | null; // The stagit binary; null publishes without the history pages, as a // machine without stagit does. Default: resolveStagit(paths.stagitBin). stagit?: string | null; // The history's render cache; null renders without one. Default: // paths.sourceHistoryCacheDir (~/.cache/archilyzer/source-history). historyCacheDir?: string | null; // The history's cap. Default: SOURCE_HISTORY_MAX_COMMITS (the tests' seam). maxHistoryCommits?: number; // The file limit the history's last-resort drop checks. Default: MAX_FILES // (the tests' seam; step 14 always applies MAX_FILES). historyFileLimit?: number; // The design tokens the history pages' style.css is written from. Default: // /common/styles/tokens.css. tokensFile?: string; // Where the scratch dir is made. Default: paths.sourceScratchDir. scratchRoot?: string; // The environment the children run in (PATH decides which tools). Default: // process.env. env?: NodeJS.ProcessEnv; now?: () => Date; // The home dir the built-in rule scrubs. Default: os.homedir(). homeDir?: string; // A checkout with no git repository: "refuse" (the CLI: exit 1) or "empty" // (buildHomepage: the /source page's empty state, exit 0). Either way it // says NO_REPOSITORY and removes an old publish. noRepository?: "refuse" | "empty"; }; // ── the operator's files ──────────────────────────────────────────────────── export type ScrubRules = { // replace.txt as filter-repo will read it: the built-in rule first, then // the operator's rules in order (comments and blank lines dropped — // filter-repo itself would treat a `#` line as a literal to replace). lines: string[]; // Every rule's LITERAL left side: denied, exact case, by implication, with // where it was written (the report's name for it). denied: Array<{ text: string; from: string }>; }; export const BUILT_IN_HOME_RULE = "built-in home rule"; // CLAUDE SESSION LINKS NEVER SHIP (operator, 2026-10-09). The session trailer // a coding agent appends to a commit message, or a bare link to a session, // names a private session of the operator's account. Every published history // is rewritten without them — the trailer line goes from every commit message // and every file, a bare link becomes a placeholder — and the audit then // refuses any that is left. Applied after the operator's rules (filter-repo // reads both files in order). The strings are assembled from parts so that // this file, which is published too, holds neither a trailer nor a link. export const BUILT_IN_SESSION_RULE = "built-in session-link rule"; export const SESSION_TRAILER = ["Claude", "Session"].join("-") + ":"; export const SESSION_LINK_LITERAL = ["claude.ai", "code", "session"].join("/"); export const SESSION_LINK_RULES: readonly string[] = [ String.raw`regex:\n?[ \t>]*` + SESSION_TRAILER + String.raw`[^\n]*==>`, String.raw`regex:https?://` + SESSION_LINK_LITERAL.replace(/\./g, String.raw`\.`) + String.raw`[A-Za-z0-9_/-]*==>[session link removed]`, ]; /** * The scrub file's text as rules. A line is `lhs==>rhs` (split at the LAST * `==>`, as filter-repo splits it), `literal:lhs==>rhs`, `regex:…==>…` or * `glob:…==>…`; a line with no `==>` is replaced by filter-repo's * `***REMOVED***`. Lines whose first non-blank character is `#` are comments. * A byte-order mark and CRLF endings are dropped first (operatorLines), and * the home dir loses a trailing `/`: either would make a rule — and the * denial it implies — silently match nothing. An empty left side is a * refusal, not a rule filter-repo would skip. */ export function parseScrubRules(text: string, homeDir: string): ScrubRules { const lines: string[] = []; const denied: ScrubRules["denied"] = []; const home = homeDir.replace(/\/+$/, ""); // A home dir of `/` (a container user) would scrub every slash, and one // that IS the replacement would deny the replacement itself. if (home.length > 1 && home !== HOME_REPLACEMENT) { lines.push(`${home}==>${HOME_REPLACEMENT}`); denied.push({ text: home, from: BUILT_IN_HOME_RULE }); } operatorLines(text).forEach((line, n) => { const t = line.trim(); if (t === "" || t.startsWith("#")) return; const i = line.lastIndexOf("==>"); let lhs = i === -1 ? line : line.slice(0, i); const from = `scrub line ${n + 1} lhs`; for (const prefix of ["regex:", "glob:", "literal:"]) { if (lhs.startsWith(prefix)) { if (lhs.length === prefix.length) { throw new SourceRefusal(`scrub line ${n + 1} has an empty left side — it would match nothing and deny nothing`); } if (prefix === "literal:") lhs = lhs.slice(prefix.length); else lhs = ""; break; } } if (i === 0) { throw new SourceRefusal(`scrub line ${n + 1} has an empty left side — it would match nothing and deny nothing`); } lines.push(line); if (lhs) denied.push({ text: lhs, from }); }); return { lines, denied }; } export type SourceRules = { scrub: ScrubRules; literals: Literal[]; // Changes whenever a rule or a literal does: part of the skip key. Never // published (a hash of the denylist would confirm a guess at it). rulesHash: string; }; async function readOperatorFile(file: string, what: string, how: string): Promise { try { return await readFile(file, "utf8"); } catch (err) { if ((err as NodeJS.ErrnoException).code === "ENOENT") { throw new SourceRefusal(`no ${what} at ${tildify(file)} — ${how}`); } throw err; } } /** Both operator files, parsed, with the literal list the gate searches for. */ export async function loadSourceRules(opts: { scrubFile: string; denylistFile: string; homeDir?: string; }): Promise { const scrubText = await readOperatorFile( opts.scrubFile, "scrub rules", "create it (git-filter-repo `lhs==>rhs` lines; the home-directory rule is built in, so it may be empty) or point SOURCE_SCRUB_FILE at one", ); const denyText = await readOperatorFile( opts.denylistFile, "denylist", "create it (one literal per line, `i:` for any case; every scrub rule's left side is denied too, so it may be empty) or point SOURCE_DENYLIST_FILE at one", ); const parsed = parseScrubRules(scrubText, opts.homeDir ?? os.homedir()); const scrub: ScrubRules = { ...parsed, lines: [...parsed.lines, ...SESSION_LINK_RULES] }; const literals = dedupeLiterals([ ...parseDenylist(denyText), ...scrub.denied.map((d) => ({ bytes: Buffer.from(d.text, "utf8"), ci: false, from: d.from })), { bytes: Buffer.from(SESSION_LINK_LITERAL, "utf8"), ci: true, from: BUILT_IN_SESSION_RULE }, ]); return { scrub, literals, rulesHash: rulesHashOf(scrub.lines, literals, SOURCE_STEP_VERSION) }; } /** * The rules hash: the scrub lines, every literal (and whether it is `i:`), * and the step's version — so a fix to the step moves it like a new rule does. */ export function rulesHashOf(lines: readonly string[], literals: readonly Literal[], step: number): string { return createHash("sha256") .update( JSON.stringify({ step, rules: lines, literals: literals.map((l) => `${l.ci ? "i" : "x"}:${l.bytes.toString("hex")}`), }), ) .digest("hex"); } // ── children ──────────────────────────────────────────────────────────────── type Ctx = { onLog: (line: string) => void; signal: AbortSignal; env: NodeJS.ProcessEnv; // Every echoed or quoted child line is masked with these once they are known. literals: readonly Literal[]; }; class Cancelled extends Error {} /** * One child through runChildIntoLog, with a timeout. Returns its combined * output. A non-zero exit or a timeout is a refusal quoting its last lines * (masked); a cancel throws Cancelled. */ async function run( ctx: Ctx, command: string, args: string[], o: { cwd: string; timeoutMs: number; echo?: boolean; allowFail?: boolean }, ): Promise<{ code: number; out: string[] }> { const out: string[] = []; const timeout = AbortSignal.timeout(o.timeoutMs); const code = await runChildIntoLog( (line) => { out.push(line); if (o.echo) ctx.onLog(`[source] ${maskLiterals(line, ctx.literals)}`); }, AbortSignal.any([ctx.signal, timeout]), { command, args, cwd: o.cwd, env: ctx.env }, ); if (ctx.signal.aborted) throw new Cancelled(); // `git --git-dir repack …` is named by its verb, not the path. const what = `${command} ${(args[0] === "--git-dir" ? args.slice(2, 3) : args.slice(0, 2)).join(" ")}`; if (timeout.aborted) { throw new SourceRefusal(`${what} timed out after ${Math.round(o.timeoutMs / 1000)} s`); } if (code !== 0 && !o.allowFail) { const tail = out.slice(-3).map((l) => maskLiterals(l, ctx.literals)).join(" / "); throw new SourceRefusal(`${what} exited ${code}${tail ? `: ${tail}` : ""}`); } return { code, out }; } const lastLine = (out: string[]) => (out.filter((l) => l.trim()).at(-1) ?? "").trim(); const OID = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/; async function revParse(ctx: Ctx, gitDir: string, rev: string): Promise { const { out } = await run(ctx, "git", ["--git-dir", gitDir, "rev-parse", "--verify", rev], { cwd: gitDir, timeoutMs: 30_000, }); const oid = lastLine(out); if (!OID.test(oid)) throw new SourceRefusal(`git rev-parse ${rev}: not an object id`); return oid; } export type FilterRepoChoice = { argv: string[]; label: string; version: string }; /** * Which git-filter-repo runs: an installed `git filter-repo`, else `pipx run` * of the pinned version (network on first use), else a refusal with the * install line. */ export async function resolveFilterRepo(opts: { env?: NodeJS.ProcessEnv; signal?: AbortSignal; onLog?: (line: string) => void; }): Promise { const ctx: Ctx = { onLog: opts.onLog ?? (() => {}), signal: opts.signal ?? new AbortController().signal, env: cleanGitEnv(opts.env ?? process.env), literals: [], }; const cwd = os.tmpdir(); const installed = await run(ctx, "git", ["filter-repo", "--version"], { cwd, timeoutMs: 30_000, allowFail: true, }); if (installed.code === 0) { return { argv: ["git", "filter-repo"], label: "git filter-repo", version: lastLine(installed.out) }; } if (!onPath("pipx", ctx.env.PATH)) { throw new SourceRefusal( `git-filter-repo is not installed and pipx is not on PATH — install it once: \`${FILTER_REPO_INSTALL}\` (pipx comes from your OS's packages)`, ); } const argv = ["pipx", "run", "--spec", FILTER_REPO_PIPX_SPEC, "git-filter-repo"]; const viaPipx = await run(ctx, argv[0], [...argv.slice(1), "--version"], { cwd, timeoutMs: 300_000, allowFail: true, }); if (viaPipx.code !== 0) { throw new SourceRefusal( `\`pipx run --spec ${FILTER_REPO_PIPX_SPEC}\` failed (exit ${viaPipx.code}; it needs the network on first use) — install it once: \`${FILTER_REPO_INSTALL}\``, ); } return { argv, label: `pipx run --spec ${FILTER_REPO_PIPX_SPEC} git-filter-repo`, version: lastLine(viaPipx.out), }; } // ── helpers ───────────────────────────────────────────────────────────────── function terminalLog(line: string): void { process.stdout.write(line.endsWith("\n") ? line : `${line}\n`); } function sha256File(file: string): Promise { return new Promise((resolve, reject) => { const h = createHash("sha256"); createReadStream(file) .on("data", (c) => h.update(c)) .on("error", reject) .on("end", () => resolve(h.digest("hex"))); }); } async function walkFiles(dir: string): Promise> { const out: Array<{ rel: string; bytes: number }> = []; const walk = async (rel: string) => { for (const ent of await readdir(path.join(dir, rel), { withFileTypes: true })) { const r = rel ? `${rel}/${ent.name}` : ent.name; if (ent.isDirectory()) await walk(r); else out.push({ rel: r, bytes: (await stat(path.join(dir, r))).size }); } }; await walk(""); return out; } const mb = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); /** * Why `files` may not be published on Pages, as one sentence — or null. The * step's limits sit inside the host's: 15,000 files of its 20,000 per * deployment (the rest of the site needs room), 24 MiB of its 25 MiB per file. */ export function limitProblem(files: ReadonlyArray<{ rel: string; bytes: number }>): string | null { if (files.length > MAX_FILES) { return `${files.length} files to publish, over the step's limit of ${MAX_FILES} (Pages allows 20,000 per deployment)`; } const big = files.find((f) => f.bytes > MAX_FILE_BYTES); if (big) { return `${big.rel} is ${mb(big.bytes)} MiB, over the step's limit of ${mb(MAX_FILE_BYTES)} MiB (Pages allows 25 MiB per file)`; } return null; } /** Where publishSource writes, for a given checkout. */ export function sourcePublicDir(paths: Paths, override?: string): string { return override ?? process.env.HOMEPAGE_PUBLIC_DIR ?? path.join(paths.monorepoRoot, "homepage", "public"); } // The skip key, kept BESIDE the public dir, never in it: it holds the rules // hash, and public/ is deployed. It is also what the homepage's deploy stage checks // homepage/out's source against (publishedSourceProblem). function statePath(publicDir: string): string { return path.join(path.dirname(publicDir), ".source-publish.json"); } type PublishState = { sourceCommit: string; mirrorHead: string; // The rules, the literals and SOURCE_STEP_VERSION. rulesHash: string; // "