commit 62fad5ab343ebc02077f667bbe6af37696feb1ad
parent ec6eeadbc88f01c3fd43d861865d7a874fd0dc03
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 5 Oct 2026 15:38:40 -0400
reports: compose publishes each report's history (history.json + a dumb-HTTP clone); the history page; the report's revision line
Compose reads each published report's revisions into
reports/<id>/history/history.json (summary and claim word diff per revision)
and stages an allowlisted dumb-HTTP clone at reports/<id>/history/repo/. The
page view carries the newest revision; the report header shows
"Revision N · <date> · history". /reports/<id>/history/ lists every revision
with its commit, report sha256, summary and inline diff. The audit refuses
anything outside the clone's allowlist and any file over the publish limit,
and counts the clone's files toward Pages' file limit.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
11 files changed, 466 insertions(+), 30 deletions(-)
diff --git a/common/lib/builtExport.test.ts b/common/lib/builtExport.test.ts
@@ -17,6 +17,7 @@ import {
PAGES_MAX_FILES,
PUBLISH_MAX_FILE_BYTES,
publishFileSizeProblem,
+ reportHistoryProblem,
} from "./builtExport";
function tempOut(siteJson?: string): { dir: string; cleanup: () => void } {
@@ -375,6 +376,63 @@ test("a cited build over the Pages file limit is refused", () => {
}
});
+// A report's published history clone (publish/reportHistory.ts).
+const HISTORY_REPO_FILES = [
+ "reports/demo-report/history/index.html",
+ "reports/demo-report/history/history.json",
+ "reports/demo-report/history/repo/HEAD",
+ "reports/demo-report/history/repo/packed-refs",
+ "reports/demo-report/history/repo/info/refs",
+ "reports/demo-report/history/repo/objects/info/packs",
+ "reports/demo-report/history/repo/refs/heads/main",
+ `reports/demo-report/history/repo/objects/pack/pack-${"a".repeat(40)}.pack`,
+ `reports/demo-report/history/repo/objects/pack/pack-${"a".repeat(40)}.idx`,
+];
+
+test("a report's history clone passes the audit; anything else in it is refused, full build or cited", () => {
+ const t = citedOut(HISTORY_REPO_FILES);
+ try {
+ assert.equal(reportHistoryProblem(t.dir), null);
+ assert.equal(builtBundleProblem(t.dir, "reports-site"), null);
+ assert.equal(builtSiteProblem(t.dir, "reports-site"), null);
+ for (const leak of ["config", "hooks/pre-commit", "logs/HEAD", "description", "FETCH_HEAD"]) {
+ const f = path.join(t.dir, "reports", "demo-report", "history", "repo", leak);
+ mkdirSync(path.dirname(f), { recursive: true });
+ writeFileSync(f, "x");
+ assert.match(reportHistoryProblem(t.dir)!, new RegExp(`history clone that are not published: reports/demo-report/history/repo/${leak}`));
+ assert.match(builtBundleProblem(t.dir, "reports-site")!, /history clone/);
+ rmSync(f);
+ }
+ } finally {
+ t.cleanup();
+ }
+ const full = bundle({ site: { siteId: "anilyzer" }, corpus: { spec: 5, site: { id: "anilyzer" } } });
+ try {
+ const f = path.join(full.dir, "reports", "r1", "history", "repo", "config");
+ mkdirSync(path.dirname(f), { recursive: true });
+ writeFileSync(f, "x");
+ assert.match(builtSiteProblem(full.dir, "anilyzer")!, /history clone/);
+ } finally {
+ full.cleanup();
+ }
+});
+
+test("a history clone's pack over the publish limit is refused; its files count toward Pages' file limit", () => {
+ const t = citedOut(HISTORY_REPO_FILES);
+ try {
+ const pack = path.join(t.dir, HISTORY_REPO_FILES[7]);
+ writeFileSync(pack, Buffer.alloc(PUBLISH_MAX_FILE_BYTES + 1));
+ assert.match(reportHistoryProblem(t.dir)!, /pack-a+\.pack is 24\.0 MiB, over the publish limit/);
+ writeFileSync(pack, "x");
+ const dir = path.join(t.dir, "m", "many");
+ mkdirSync(dir, { recursive: true });
+ for (let i = 0; i <= PAGES_MAX_FILES; i++) writeFileSync(path.join(dir, String(i)), "");
+ assert.match(citedBuildProblem(t.dir)!, /\(7 in report history clones\), over Pages' limit of 20000/);
+ } finally {
+ t.cleanup();
+ }
+});
+
test("a site configured cited with a full build is refused at deploy; a cited build of it is not", () => {
const full = bundle({ site: { siteId: "reports-site" }, corpus: { spec: 5, site: { id: "reports-site" } } });
const cited = citedOut();
diff --git a/common/lib/builtExport.ts b/common/lib/builtExport.ts
@@ -16,6 +16,7 @@
import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "node:fs";
import path from "node:path";
import { isCitedSite } from "./siteSchema";
+import { REPORT_HISTORY_REPO_FILE_RE } from "./report/revisions";
/**
* The site id of the build sitting in `outDir`, or null when there is no
@@ -65,7 +66,7 @@ export function builtSiteProblem(outDir: string, siteId: string): string | null
if (corpusSiteIdIn(outDir) !== asked) {
return `export/out holds an incomplete build of "${asked}" (its corpus.json does not name it) — build ${asked} first`;
}
- return citedBuildProblem(outDir);
+ return citedBuildProblem(outDir) ?? reportHistoryProblem(outDir);
}
/**
@@ -96,7 +97,7 @@ export function builtBundleProblem(outDir: string, siteId: string): string | nul
if (described !== asked) {
return `${outDir} describes "${described}", not "${asked}" (corpus.json)`;
}
- return citedBuildProblem(outDir);
+ return citedBuildProblem(outDir) ?? reportHistoryProblem(outDir);
}
/**
@@ -291,8 +292,10 @@ export function citedBuildProblem(outDir: string): string | null {
`but it also holds ${shown} — compose the site again`
);
}
- // The Pages limits, which a cited build is small enough to walk for.
+ // The Pages limits, which a cited build is small enough to walk for —
+ // every file counted, the reports' history clones' included.
let files = 0;
+ let historyFiles = 0;
const oversize: string[] = [];
const walk = (dir: string, rel: string): void => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
@@ -301,6 +304,7 @@ export function citedBuildProblem(outDir: string): string | null {
if (e.isDirectory()) walk(p, r);
else {
files++;
+ if (REPORT_HISTORY_REPO_DIR_RE.test(r)) historyFiles++;
if (statSync(p).size > PAGES_MAX_FILE_BYTES) oversize.push(r);
}
}
@@ -310,8 +314,60 @@ export function citedBuildProblem(outDir: string): string | null {
return `${outDir} holds ${oversize.length} file(s) over Pages' 25 MiB limit: ${oversize.slice(0, 5).join(", ")}`;
}
if (files > PAGES_MAX_FILES) {
- return `${outDir} holds ${files} files, over Pages' limit of ${PAGES_MAX_FILES}`;
+ return (
+ `${outDir} holds ${files} files${historyFiles ? ` (${historyFiles} in report history clones)` : ""}, ` +
+ `over Pages' limit of ${PAGES_MAX_FILES}`
+ );
+ }
+ return null;
+}
+
+// A file inside a report's published history clone, by its out/-relative path.
+const REPORT_HISTORY_REPO_DIR_RE = /^reports\/[^/]+\/history\/repo\//;
+
+/**
+ * Why the reports' history clones in `outDir` (`reports/<id>/history/repo/`,
+ * publish/reportHistory.ts) may not ship, as one sentence — or null. Any
+ * build, full or cited: a clone may hold only the dumb-HTTP files its stager
+ * copies (REPORT_HISTORY_REPO_FILE_RE — never a config, hook or log of the
+ * store), each within the publish limit (PUBLISH_MAX_FILE_BYTES).
+ */
+export function reportHistoryProblem(outDir: string): string | null {
+ const reportsDir = path.join(outDir, "reports");
+ let reports: Dirent[];
+ try {
+ reports = readdirSync(reportsDir, { withFileTypes: true });
+ } catch {
+ return null;
+ }
+ const extra: string[] = [];
+ const oversize: string[] = [];
+ for (const r of reports) {
+ if (!r.isDirectory()) continue;
+ const repo = path.join(reportsDir, r.name, "history", "repo");
+ if (!existsSync(repo)) continue;
+ const walk = (dir: string, rel: string): void => {
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, e.name);
+ const rr = rel ? `${rel}/${e.name}` : e.name;
+ if (e.isDirectory()) walk(p, rr);
+ else {
+ if (!REPORT_HISTORY_REPO_FILE_RE.test(rr)) extra.push(`reports/${r.name}/history/repo/${rr}`);
+ const problem = publishFileSizeProblem(`reports/${r.name}/history/repo/${rr}`, statSync(p).size);
+ if (problem) oversize.push(problem);
+ }
+ }
+ };
+ walk(repo, "");
+ }
+ if (extra.length > 0) {
+ extra.sort();
+ return (
+ `${outDir} holds files in a report's history clone that are not published: ${extra.slice(0, 8).join(", ")}` +
+ `${extra.length > 8 ? `, … (${extra.length} in all)` : ""} — compose the site again`
+ );
}
+ if (oversize.length > 0) return `${outDir}: ${oversize[0]}`;
return null;
}
diff --git a/common/lib/report/revisions.ts b/common/lib/report/revisions.ts
@@ -34,6 +34,12 @@ export function reportHistoryRepoPath(reportId: string): string {
return `/reports/${reportId}/history/repo`;
}
+// What a published clone holds, by path relative to it (publish/reportHistory.ts
+// copies nothing else; lib/builtExport.ts refuses anything else under a
+// `reports/<id>/history/repo/`).
+export const REPORT_HISTORY_REPO_FILE_RE =
+ /^(HEAD|packed-refs|info\/refs|objects\/info\/packs|refs\/heads\/main|objects\/pack\/pack-[0-9a-f]+\.(pack|idx))$/;
+
// ─── Views ───
// One run of words in a diff: the same in both, inserted, or deleted.
diff --git a/common/lib/report/views.ts b/common/lib/report/views.ts
@@ -6,6 +6,8 @@
// /reports/<reportId>/page.json ReportPageView one report, its citations resolved
// /reports/<reportId>/citations.{json,csv} the report's citations, for download
// /reports/<reportId>/stills/… a source citation's still, as the report names it
+// /reports/<reportId>/history/history.json ReportHistoryView its revisions (lib/report/revisions.ts)
+// /reports/<reportId>/history/repo/ a dumb-HTTP clone of its revision history
// /m/index.json MomentIndexView every moment page to build
// /m/<momentKey>/moment.json MomentPageView one moment: clip, cues, record, "cited in"
// /media/clips/<channel>/<id>/<start>-<end>.mp4 a span's evidence clip
@@ -52,6 +54,7 @@ import type {
import { quoteTokens } from "../citations/verify";
import { formatTimestamp } from "../vtt";
import type { CitedIn } from "./citedIn";
+import type { ReportHistoryRef } from "./revisions";
import type { Claim, Report, ReportKind } from "./schema";
import { reportCitationNumbers } from "./uses";
import { resolveVerdicts, VERDICTS, type Verdict, type VerdictStyle } from "./verdicts";
@@ -280,6 +283,9 @@ export type ReportPageView = {
// The report as files, and its citations as data, when compose published
// them (each a site-root path).
downloads?: ReportDownloads;
+ // Its newest revision and the history page (lib/report/revisions.ts), when
+ // compose published a history.
+ history?: ReportHistoryRef;
};
// What a report page offers to download: its exports (html, pdf, md, the
@@ -386,6 +392,7 @@ export type ReportViewResolver = {
poster?: (c: SpanCitation) => string | undefined;
post?: (c: PostCitation) => { author?: string; text?: string; shot?: string } | undefined;
downloads?: ReportDownloads;
+ history?: ReportHistoryRef;
};
function sourceView(id: string, s: Source): SourceView {
@@ -541,6 +548,7 @@ export function buildReportPageView(report: Report, resolve: ReportViewResolver)
}),
),
downloads: resolve.downloads,
+ history: resolve.history,
});
}
diff --git a/common/publish/composeReports.test.ts b/common/publish/composeReports.test.ts
@@ -47,7 +47,7 @@ const { REPORT_MEDIA_FORMAT, REPORT_MEDIA_VERSION, reportMediaDir, reportMediaIn
const { QUOTE_CHECK_METHOD } = await import("../lib/citations/verify");
const { parseCitationSet } = await import("../lib/citations/validate");
const { CONTRACT } = await import("../lib/archive/contract");
-const { citedBuildProblem, builtBundleProblem } = await import("../lib/builtExport");
+const { citedBuildProblem, builtBundleProblem, reportHistoryProblem } = await import("../lib/builtExport");
const paths = getPaths();
const VIDEOS = "demo-channel";
@@ -640,6 +640,72 @@ test("compose publishes the exports beside the report and lists them as download
assert.equal(citedBuildProblem(paths.exportPublicDir), null, "the exports are within reports/, which the audit allows");
});
+test("reports export commits a revision when report.json changed; compose publishes the history, the page names it", async () => {
+ const dir = reportExportDir(paths, "cited", REPORT);
+ const file = path.join(paths.sitesDir, "cited", "reports", REPORT, "report.json");
+ const saved = readFileSync(file, "utf8");
+ // The exports above made revision 1; exporting the same report.json again made none.
+ const first = readJson<{ revision: { revision: number; committed: boolean; commit: string }; footer: { revision?: number } }>(
+ path.join(dir, "export.json"),
+ );
+ assert.equal(first.revision.revision, 1);
+ assert.equal(first.revision.committed, false);
+ assert.equal(first.footer.revision, 1);
+ assert.match(readFileSync(path.join(dir, "report.md"), "utf8"), /Revision 1 · 2026-10-01 · report sha256 [0-9a-f]{12}/);
+
+ const edited = report();
+ edited.sections[0].claims[0] = { ...edited.sections[0].claims[0], text: "He opened the old bridge himself.", verdict: "PARTLY" };
+ writeJson(file, edited);
+ try {
+ const r = await exportSiteReports({
+ siteId: "cited",
+ paths,
+ now: () => new Date("2026-10-06T08:00:00Z"),
+ openPdfPrinter: fakePrinter([]),
+ onLog: () => {},
+ });
+ assert.deepEqual(r.problems, []);
+ const m = r.exported[0].manifest;
+ assert.equal(m.revision?.revision, 2);
+ assert.equal(m.revision?.committed, true);
+ assert.deepEqual(m.revision?.summary, ["Verdict changed: claim-1 CONTRADICTED → PARTLY", "Claim edited: claim-1 (text)"]);
+ assert.match(readFileSync(path.join(dir, "report.html"), "utf8"), /Revision 2 · 2026-10-01 · report sha256/);
+
+ await compose("cited");
+ const history = readJson<{
+ clone: string;
+ revisions: { revision: number; commit: string; reportSha256: string; date: string; summary: string[]; claims: { id: string }[] }[];
+ }>(pub("reports", REPORT, "history", "history.json"));
+ assert.equal(history.clone, `https://cited.example.test/reports/${REPORT}/history/repo`);
+ assert.deepEqual(history.revisions.map((x) => x.revision), [1, 2]);
+ assert.equal(history.revisions[1].commit, m.revision?.commit);
+ assert.equal(history.revisions[1].reportSha256, sha(file));
+ assert.equal(history.revisions[1].date, "2026-10-06T08:00:00Z");
+ assert.deepEqual(history.revisions[1].claims.map((c) => c.id), ["claim-1"]);
+ const repo = filesUnder(pub("reports", REPORT, "history", "repo"));
+ assert.deepEqual(repo.filter((f) => !f.startsWith("objects/pack/")), ["HEAD", "info/refs", "objects/info/packs", "packed-refs", "refs/heads/main"]);
+ assert.ok(repo.some((f) => f.endsWith(".pack")));
+ const view = readJson<{ history: unknown }>(pub("reports", REPORT, "page.json"));
+ assert.deepEqual(view.history, { revision: 2, date: "2026-10-06T08:00:00Z", href: `/reports/${REPORT}/history/`, current: true });
+ assert.match(readFileSync(pub("sitemap.xml"), "utf8"), new RegExp(`/reports/${REPORT}/history/`));
+ assert.equal(citedBuildProblem(paths.exportPublicDir), null);
+ assert.equal(reportHistoryProblem(paths.exportPublicDir), null);
+ } finally {
+ writeFileSync(file, saved);
+ }
+ // Edited since the newest revision and not exported: the page says so.
+ await compose("cited");
+ const view = readJson<{ history: { revision: number; current: boolean } }>(pub("reports", REPORT, "page.json"));
+ assert.deepEqual([view.history.revision, view.history.current], [2, false]);
+ // Exported again, the way back is revision 3.
+ const back = await exportSiteReports({ siteId: "cited", paths, now: () => new Date("2026-10-06T09:00:00Z"), openPdfPrinter: fakePrinter([]), onLog: () => {} });
+ assert.equal(back.exported[0].manifest.revision?.revision, 3);
+ assert.deepEqual(back.exported[0].manifest.revision?.summary, ["Verdict changed: claim-1 PARTLY → CONTRADICTED", "Claim edited: claim-1 (text)"]);
+ // A site with no reports ships no history.
+ await compose("plain");
+ assert.ok(!existsSync(pub("reports")));
+});
+
test("an export of another version of the report is not published; a pack over the limit stays local", async () => {
const dir = reportExportDir(paths, "cited", REPORT);
const file = path.join(paths.sitesDir, "cited", "reports", REPORT, "report.json");
diff --git a/common/publish/composeReports.ts b/common/publish/composeReports.ts
@@ -33,6 +33,10 @@
// an `archilyzer-citations` set;
// never a source's `saved` copy)
// reports/<id>/<still> each cited source still
+// reports/<id>/history/history.json, the report's revisions and a
+// history/repo/… dumb-HTTP clone of them, when
+// `reports export` has committed
+// one (./reportHistory.ts)
// reports/<id>/report.{html,pdf,md}, the report's exports, when
// evidence-pack.zip `archilyzer reports export` made
// them from the report as it is now
@@ -76,6 +80,7 @@ import { momentKeyOf, momentPath, parseMomentKey, type SpanMoment } from "../lib
import { CITATIONS_VERSION, type Citation, type PostCitation, type SpanCitation } from "../lib/citations/schema";
import { cueWindowText, quoteDrifted, quoteVerification, QUOTE_DRIFT_THRESHOLD } from "../lib/citations/verify";
import { buildCitedIn } from "../lib/report/citedIn";
+import { reportHistoryPagePath } from "../lib/report/revisions";
import type { Report } from "../lib/report/schema";
import { reportCitationNumbers } from "../lib/report/uses";
import {
@@ -114,7 +119,9 @@ import {
siteReportDir,
type ReportMediaEntry,
} from "./reportMedia";
-import { publishableReportExports, type PublishableReportExports } from "./reportExportFiles";
+import { publishableReportExports, reportFileSha256, type PublishableReportExports } from "./reportExportFiles";
+import { publishReportHistory, readReportHistoryView, reportHistoryGitDir, reportHistoryRef } from "./reportHistory";
+import type { ReportHistoryRef, ReportHistoryView } from "../lib/report/revisions";
// The public dir's entries this stage owns. Every compose removes them first.
export const REPORT_PUBLIC_ENTRIES: readonly string[] = ["reports", "m", "media"];
@@ -206,6 +213,8 @@ export type ComposedReports = {
moments: string[];
// Media problems let through by `allowMissingMedia`.
allowed: ComposeReportsProblem[];
+ // The reports whose revision history was published.
+ histories?: string[];
};
// ─── Reading the corpus ───
@@ -396,6 +405,8 @@ const sameSpan = (a: EvidenceSpan, b: EvidenceSpan) =>
export type ResolveSiteReportsOptions = Omit<ComposeReportsOptions, "publicDir"> & {
// Each report's downloads, as its view carries them.
downloads?: (reportId: string) => ReportDownloads | undefined;
+ // Each report's newest revision, as its view carries it.
+ history?: (reportId: string) => ReportHistoryRef | undefined;
};
// The site's reports resolved against the corpus — verified, their views and
@@ -647,6 +658,7 @@ export async function resolveSiteReports(opts: ResolveSiteReportsOptions): Promi
return post ? { author: postAuthor(post), text: post.text, shot: postShot(c) } : undefined;
},
downloads: opts.downloads?.(report.id),
+ history: opts.history?.(report.id),
}),
);
@@ -752,8 +764,30 @@ export async function composeReports(opts: ComposeReportsOptions): Promise<Compo
exportsOf.set(id, found);
for (const note of found.notes) log(`[reports] ${id}: ${note}`);
}
+ // Each report's revision history (publish/reportHistory.ts), read before
+ // anything is written; a report never exported has none.
+ const histories = new Map<string, { view: ReportHistoryView; ref: ReportHistoryRef; gitDir: string }>();
+ const historyProblems: ComposeReportsProblem[] = [];
+ for (const id of site.reports ?? []) {
+ const gitDir = reportHistoryGitDir(paths, site.siteId, id);
+ try {
+ const view = await readReportHistoryView(gitDir, { reportId: id, siteUrl: site.siteUrl });
+ if (!view) continue;
+ const ref = reportHistoryRef(view, await reportFileSha256(paths, site.siteId, id));
+ if (!ref.current) log(`[reports] ${id}: report.json changed since revision ${ref.revision} — export again to commit it`);
+ histories.set(id, { view, ref, gitDir });
+ } catch (e) {
+ historyProblems.push({
+ kind: "unreadable",
+ report: id,
+ message: `its revision history (${gitDir}) cannot be read: ${String((e as Error)?.message ?? e).split("\n")[0]}`,
+ });
+ }
+ }
+ if (historyProblems.length > 0) throw new ComposeReportsError(historyProblems);
const { reports, views, index, moments, mediaOf, cacheDir, allowed } = await resolveSiteReports({
...opts,
+ history: (id) => histories.get(id)?.ref,
downloads: (id) => ({
...Object.fromEntries(
REPORT_EXPORT_FORMATS.filter((f) => exportsOf.get(id)?.files[f]).map((f) => [f, reportExportDownloadPath(id, f)]),
@@ -782,6 +816,11 @@ export async function composeReports(opts: ComposeReportsOptions): Promise<Compo
const src = exported[f];
if (src) await copyOut(publicDir, src, reportExportDownloadPath(report.id, f));
}
+ const history = histories.get(report.id);
+ if (history) {
+ const files = await publishReportHistory({ gitDir: history.gitDir, publicDir, view: history.view });
+ log(`[reports] ${report.id}: revision ${history.ref.revision}, history published (${files.length} repository files).`);
+ }
}
await writeOut(
publicDir,
@@ -807,7 +846,7 @@ export async function composeReports(opts: ComposeReportsOptions): Promise<Compo
`[reports] ${reports.length} report(s), ${moments.length} moment page(s), ${copied} media file(s)` +
`${allowed.length ? `, ${allowed.length} without media` : ""}.`,
);
- return { reports: index.reports, moments: moments.map((m) => m.key), allowed };
+ return { reports: index.reports, moments: moments.map((m) => m.key), allowed, histories: [...histories.keys()] };
}
// A clip's published path: the moment's (lib/report/views.ts), `.m4a` for a
@@ -825,9 +864,14 @@ function postAuthor(post: Post): string {
const defined = <T extends object>(o: T): T =>
Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined)) as T;
-// The site-root routes the reports add to a sitemap: the index, each report,
-// each moment page.
-export function reportRoutes(composed: Pick<ComposedReports, "reports" | "moments">): string[] {
+// The site-root routes the reports add to a sitemap: the index, each report
+// (and its history page, when it has one), each moment page.
+export function reportRoutes(composed: Pick<ComposedReports, "reports" | "moments" | "histories">): string[] {
if (composed.reports.length === 0) return [];
- return ["/reports/", ...composed.reports.map((r) => r.href), ...composed.moments.map((k) => momentPath(k))];
+ const histories = new Set(composed.histories ?? []);
+ return [
+ "/reports/",
+ ...composed.reports.flatMap((r) => (histories.has(r.id) ? [r.href, reportHistoryPagePath(r.id)] : [r.href])),
+ ...composed.moments.map((k) => momentPath(k)),
+ ];
}
diff --git a/common/publish/reportHistory.ts b/common/publish/reportHistory.ts
@@ -55,6 +55,7 @@ import type { Report } from "../lib/report/schema";
import {
REPORT_HISTORY_BRANCH,
REPORT_HISTORY_FORMAT,
+ REPORT_HISTORY_REPO_FILE_RE,
REPORT_HISTORY_VERSION,
claimChanges,
reportChangeSummary,
@@ -68,6 +69,8 @@ import {
type ReportRevisionView,
} from "../lib/report/revisions";
import { cleanGitEnv } from "./sourceAudit";
+
+export { REPORT_HISTORY_REPO_FILE_RE };
import { siteReportDir } from "./reportMedia";
export const REPORT_HISTORY_GIT_DIRNAME = "history-git";
@@ -113,18 +116,20 @@ export function historyGitEnv(identity?: HistoryIdentity, date?: Date): NodeJS.P
TZ: "UTC",
LC_ALL: "C",
LANG: "C",
- });
- if (identity) {
- env.GIT_AUTHOR_NAME = identity.name;
- env.GIT_AUTHOR_EMAIL = identity.email;
- env.GIT_COMMITTER_NAME = identity.name;
- env.GIT_COMMITTER_EMAIL = identity.email;
- }
- if (date) {
- env.GIT_AUTHOR_DATE = gitDateUtc(date);
- env.GIT_COMMITTER_DATE = gitDateUtc(date);
- }
- return env;
+ } as unknown as NodeJS.ProcessEnv);
+ // Set here, for the git child only (written, never read).
+ return {
+ ...env,
+ ...(identity
+ ? {
+ GIT_AUTHOR_NAME: identity.name,
+ GIT_AUTHOR_EMAIL: identity.email,
+ GIT_COMMITTER_NAME: identity.name,
+ GIT_COMMITTER_EMAIL: identity.email,
+ }
+ : {}),
+ ...(date ? { GIT_AUTHOR_DATE: gitDateUtc(date), GIT_COMMITTER_DATE: gitDateUtc(date) } : {}),
+ };
}
// Config every invocation carries on its command line, over whatever a
@@ -395,12 +400,6 @@ export function reportHistoryRef(view: ReportHistoryView, currentSha256: string
// ─── The published clone ───
-// What the published clone holds, by path relative to it: nothing else is
-// copied out of the clone, and the audit (lib/builtExport.ts) refuses
-// anything else under a `reports/<id>/history/repo/`.
-export const REPORT_HISTORY_REPO_FILE_RE =
- /^(HEAD|packed-refs|info\/refs|objects\/info\/packs|refs\/heads\/main|objects\/pack\/pack-[0-9a-f]+\.(pack|idx))$/;
-
// Stage a dumb-HTTP clone of the store at `destDir` (replaced whole): a fresh
// bare clone of the one branch, repacked, its server info written, copied
// from an allowlist. Throws when a file would be over the publish limit.
@@ -440,6 +439,7 @@ export async function stageReportHistoryRepo(gitDir: string, destDir: string): P
files.push(`objects/pack/${f}`);
}
for (const f of files) {
+ if (!REPORT_HISTORY_REPO_FILE_RE.test(f)) throw new Error(`the history clone would publish ${f}, which it may not`);
const problem = publishFileSizeProblem(f, (await stat(path.join(destDir, f))).size);
if (problem) throw new Error(`the history clone's ${problem}`);
}
diff --git a/export/app/components/reports/ReportArticle.tsx b/export/app/components/reports/ReportArticle.tsx
@@ -1,3 +1,4 @@
+import Link from "next/link";
import { ArrowDownToLine } from "lucide-react";
import { AddedMark, CitationCard } from "yt-dlp-transcript-common/components/citations/CitationCard";
import { CitationsProvider } from "yt-dlp-transcript-common/components/citations/CitationsContext";
@@ -24,7 +25,7 @@ import { ArchiveList, ReportName, SourceBlock, dateLabel, textLink } from "./par
// ONE REPORT, from its view (common/lib/report/views.ts): the header (title and
// the reviewed document's byline, then ours — "Fact-check by <site>" — and the
-// dates, the subtitle, the document under review with its archive links), a
+// dates and the revision with its history link, the subtitle, the document under review with its archive links), a
// fact-check's tally, the summary, the sections and their claims — each claim
// its verdict and flag, the document's own sentence (its still), the findings with
// their inline citations, and the evidence cards (what the report added first,
@@ -296,6 +297,17 @@ export default function ReportArticle({ view, siteTitle }: { view: ReportPageVie
{reportAttribution(view.kind, siteTitle)}
</p>
{dates.length > 0 && <p className="font-mono text-xs text-muted-foreground">{dates.join(" · ")}</p>}
+ {view.history && (
+ <p data-report-revision={view.history.revision} className="font-mono text-xs text-muted-foreground">
+ {view.history.current
+ ? `Revision ${view.history.revision} · ${dateLabel(view.history.date)}`
+ : `Edited since revision ${view.history.revision}`}
+ {" · "}
+ <Link href={view.history.href} className={textLink}>
+ history
+ </Link>
+ </p>
+ )}
</div>
{view.subtitle && <p className="text-lg text-muted-foreground">{view.subtitle}</p>}
{subject && <SourceBlock source={subject} label="Under review" />}
diff --git a/export/app/components/reports/ReportHistory.tsx b/export/app/components/reports/ReportHistory.tsx
@@ -0,0 +1,147 @@
+import Link from "next/link";
+import {
+ reportHistoryViewPath,
+ type ClaimChange,
+ type ClaimDiffField,
+ type DiffSegment,
+ type ReportHistoryView,
+ type ReportRevisionView,
+} from "yt-dlp-transcript-common/lib/report/revisions";
+import { reportPagePath } from "yt-dlp-transcript-common/lib/report/views";
+import { ReportName, textLink } from "./parts";
+
+// A REPORT'S REVISION HISTORY, from its history.json (common/lib/report/revisions.ts):
+// how to clone it, then every revision, newest first — its number, date,
+// commit and report.json sha256 (the hash every export's footer prints), the
+// change summary, and each changed claim as an inline word diff against the
+// revision before.
+
+const FIELD_LABELS: Record<ClaimDiffField, string> = {
+ title: "Title",
+ text: "Claim",
+ verdict: "Verdict",
+ findings: "Findings",
+};
+
+const STATUS_LABELS: Record<ClaimChange["status"], string> = {
+ added: "added",
+ removed: "removed",
+ edited: "edited",
+};
+
+// `2026-03-08T12:00:00Z` → `2026-03-08 12:00 UTC`.
+export function revisionDate(iso: string): string {
+ const m = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(iso);
+ return m ? `${m[1]} ${m[2]} UTC` : iso;
+}
+
+function Diff({ segments }: { segments: DiffSegment[] }) {
+ return (
+ <>
+ {segments.map((s, i) =>
+ s.op === "ins" ? (
+ <ins key={i} data-diff="ins" className="rounded-sm bg-success-soft text-foreground no-underline decoration-success">
+ {s.text}
+ </ins>
+ ) : s.op === "del" ? (
+ <del key={i} data-diff="del" className="rounded-sm bg-destructive-soft text-muted-foreground decoration-destructive">
+ {s.text}
+ </del>
+ ) : (
+ <span key={i}>{s.text}</span>
+ ),
+ )}
+ </>
+ );
+}
+
+function ClaimDiff({ change }: { change: ClaimChange }) {
+ return (
+ <div
+ data-claim-change={change.id}
+ data-status={change.status}
+ className="flex flex-col gap-1.5 rounded-md border border-border bg-card px-3 py-2 text-sm"
+ >
+ <p className="font-mono text-[11px] text-muted-foreground">
+ <span className="text-foreground">{change.id}</span> · {change.section} · {STATUS_LABELS[change.status]}
+ </p>
+ {change.fields.map((f) => (
+ <p key={f.field} data-field={f.field} className="whitespace-pre-wrap break-words leading-relaxed">
+ <span className="mr-2 font-mono text-[10px] uppercase tracking-[0.14em] text-muted-foreground">
+ {FIELD_LABELS[f.field]}
+ </span>
+ <Diff segments={f.diff} />
+ </p>
+ ))}
+ </div>
+ );
+}
+
+function Revision({ r }: { r: ReportRevisionView }) {
+ return (
+ <li id={`r${r.revision}`} data-revision={r.revision} className="flex scroll-mt-20 flex-col gap-3 border-t border-border pt-5">
+ <div className="flex flex-col gap-1">
+ <h2 className="font-display text-xl font-semibold tracking-tight text-foreground">Revision {r.revision}</h2>
+ <p className="font-mono text-xs text-muted-foreground">{revisionDate(r.date)}</p>
+ </div>
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 font-mono text-xs">
+ <dt className="text-muted-foreground">commit</dt>
+ <dd data-commit={r.commit} className="break-all text-foreground">
+ {r.commit}
+ </dd>
+ <dt className="text-muted-foreground">report sha256</dt>
+ <dd data-report-sha256={r.reportSha256} className="break-all text-foreground">
+ {r.reportSha256}
+ </dd>
+ </dl>
+ {r.summary.length > 0 && (
+ <ul data-revision-summary="" className="flex list-disc flex-col gap-0.5 pl-5 text-sm text-foreground">
+ {r.summary.map((line, i) => (
+ <li key={i}>{line}</li>
+ ))}
+ </ul>
+ )}
+ {r.claims.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {r.claims.map((c) => (
+ <ClaimDiff key={c.id} change={c} />
+ ))}
+ </div>
+ )}
+ </li>
+ );
+}
+
+export default function ReportHistory({ view }: { view: ReportHistoryView }) {
+ const revisions = [...view.revisions].reverse();
+ return (
+ <article data-report-history={view.reportId} className="mx-auto flex w-full max-w-3xl flex-col gap-6">
+ <header className="flex flex-col gap-3">
+ <p className="text-sm">
+ <Link href={reportPagePath(view.reportId)} className={textLink}>
+ <ReportName series={view.series} title={view.title} />
+ </Link>
+ </p>
+ <h1 className="font-display text-3xl font-semibold leading-tight tracking-tight text-foreground">Revision history</h1>
+ <p className="text-sm text-muted-foreground">
+ Every edit to this report, newest first. Each revision is a commit in the report's own git history; its
+ report sha256 is the hash printed at the foot of the report's downloads.{" "}
+ <a href={reportHistoryViewPath(view.reportId)} className={textLink}>
+ history.json
+ </a>
+ </p>
+ <pre
+ data-history-clone=""
+ className="overflow-x-auto rounded-md border border-border bg-muted px-3 py-2 font-mono text-xs text-foreground"
+ >
+ <code>git clone {view.clone}</code>
+ </pre>
+ </header>
+ <ol className="flex flex-col gap-6">
+ {revisions.map((r) => (
+ <Revision key={r.revision} r={r} />
+ ))}
+ </ol>
+ </article>
+ );
+}
diff --git a/export/app/lib/reports.ts b/export/app/lib/reports.ts
@@ -7,6 +7,11 @@ import { currentSite } from "./site";
import { parseMomentKey } from "yt-dlp-transcript-common/lib/citations/moments";
import { isReportId } from "yt-dlp-transcript-common/lib/report/schema";
import {
+ REPORT_HISTORY_FORMAT,
+ reportHistoryViewPath,
+ type ReportHistoryView,
+} from "yt-dlp-transcript-common/lib/report/revisions";
+import {
MOMENT_INDEX_FORMAT,
MOMENT_PAGE_FORMAT,
MOMENTS_INDEX_PATH,
@@ -62,6 +67,14 @@ export function readReportView(reportId: string): ReportPageView | null {
return v && v.id === reportId && Array.isArray(v.sections) ? v : null;
}
+// A report's revision history (/reports/<id>/history/history.json), or null
+// when compose published none for it.
+export function readReportHistory(reportId: string): ReportHistoryView | null {
+ if (!isReportId(reportId)) return null;
+ const v = readView<ReportHistoryView>(reportHistoryViewPath(reportId), REPORT_HISTORY_FORMAT);
+ return v && v.reportId === reportId && Array.isArray(v.revisions) && v.revisions.length > 0 ? v : null;
+}
+
// The build's one report, when it publishes exactly one: a cited site's home
// page IS that report.
export function onlyReportView(): ReportPageView | null {
diff --git a/export/app/reports/[reportId]/history/page.tsx b/export/app/reports/[reportId]/history/page.tsx
@@ -0,0 +1,26 @@
+import type { Metadata } from "next";
+import { reportFullTitle } from "yt-dlp-transcript-common/lib/report/views";
+import ReportHistory from "../../../components/reports/ReportHistory";
+import { EmptyState } from "../../../components/reports/parts";
+import { readReportHistory } from "../../../lib/reports";
+
+// /reports/<reportId>/history/: the report's revisions, from
+// /reports/<reportId>/history/history.json. Built for every report the parent
+// segment lists (and its placeholder); a report with no published history
+// says so.
+
+type Params = { params: Promise<{ reportId: string }> };
+
+export async function generateMetadata({ params }: Params): Promise<Metadata> {
+ const view = readReportHistory((await params).reportId);
+ if (!view) return { title: "No history" };
+ return { title: `Revision history — ${reportFullTitle(view)}` };
+}
+
+export default async function ReportHistoryPage({ params }: Params) {
+ const view = readReportHistory((await params).reportId);
+ if (!view) {
+ return <EmptyState title="No history">This report has no published revision history.</EmptyState>;
+ }
+ return <ReportHistory view={view} />;
+}