krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit e1ffd4eb2d

e1ffd4eb2dc611ab865cbe9a4742b35276271686

parent: c96da98345

Unsigned ci/pages: success ci/test: success

cmc <hello@cleberg.net> · 2026-10-18 20:47 UTC

Write the cache manifest in a deterministic order (!14)

`<out>/.orgo-cache.json` was the one file in the output tree that was not
byte-reproducible: `Manifest.pages` and both `DepGraph` edge maps were
`HashMap`/`HashSet`, so serde wrote their entries in map iteration order and
two builds of the same input with the same binary disagreed on ordering.

BTreeMap/BTreeSet throughout, plus `Ord` on `TargetId`. No schema change — same
field names, same JSON types, object keys and array elements merely ordered —
and `CACHE_FORMAT_VERSION` stays at 7. Checked against a manifest written by
0.24.1: the new binary loads it and reports `0 rendered, 2 cached`.

New test `cache_manifest_is_byte_identical_across_builds` builds a 24-page
cross-linked site four times with `--no-cache` and compares manifest bytes. It
fails before this change. No existing test asserted on manifest contents; the
byte-equivalence helper in `tests/incremental.rs` skips `.orgo-cache.json`,
which is why this went unnoticed.

This makes the whole output tree reproducible. Three `--no-cache` builds of
`docs/` into separate directories are now identical under `diff -r`, manifest
included, and an incremental rebuild over a populated cache produces the same
20 files byte for byte. Nothing else in the tree varies: the other hash inputs
(config, templates, site structure, resolved links) were already sorted before
hashing, and `Config` contains no unordered maps.

Layout: unified · split

src/incremental.rs +7 −6
@@ -17,7 +17,7 @@
1717//! invalidates pages that *link into* a changed file's targets, so a renamed/removed
1818//! heading invalidates the pages that link to it, not just the file that owns it.
1919
20use std::collections::{HashMap, HashSet};
20use std::collections::{BTreeMap, BTreeSet, HashSet};
2121
2222use camino::{Utf8Path, Utf8PathBuf};
2323use serde::{Deserialize, Serialize};
@@ -101,8 +101,8 @@ pub fn template_hash(sources: &[(String, String)]) -> Hash {
101101/// `uses`: page → targets it resolved a link to (the invalidation-critical edges).
102102#[derive(Debug, Default, Clone, Serialize, Deserialize)]
103103pub struct DepGraph {
104 pub defines: HashMap<Utf8PathBuf, HashSet<TargetId>>,
105 pub uses: HashMap<Utf8PathBuf, HashSet<TargetId>>,
104 pub defines: BTreeMap<Utf8PathBuf, BTreeSet<TargetId>>,
105 pub uses: BTreeMap<Utf8PathBuf, BTreeSet<TargetId>>,
106106}
107107
108108impl DepGraph {
@@ -129,7 +129,7 @@ impl DepGraph {
129129/// hash and therefore the page's `render_key`. Deterministic: targets are sorted.
130130pub fn resolved_links_hash(
131131 from: &Utf8Path,
132 used: &HashSet<TargetId>,
132 used: &BTreeSet<TargetId>,
133133 symbols: &SymbolTable,
134134) -> Hash {
135135 let mut pairs: Vec<(String, String)> = used
@@ -187,12 +187,13 @@ pub struct PageRecord {
187187
188188/// The on-disk cache manifest (spec §4.5). Serialized as JSON (human-diffable; the
189189/// cache is an optimization, never a correctness dependency — a `--no-cache`/`clean`
190/// run always produces byte-identical output).
190/// run always produces byte-identical output). Ordered maps throughout, so the manifest
191/// is itself byte-reproducible rather than written in map iteration order.
191192#[derive(Debug, Default, Serialize, Deserialize)]
192193pub struct Manifest {
193194 pub format_version: u32,
194195 pub config_hash: Option<Hash>,
195 pub pages: HashMap<Utf8PathBuf, PageRecord>,
196 pub pages: BTreeMap<Utf8PathBuf, PageRecord>,
196197 pub graph: DepGraph,
197198}
198199
src/index.rs +1 −1
@@ -13,7 +13,7 @@ use crate::util::{heading_anchor, output_path, plain_text};
1313///
1414/// `Serialize`/`Deserialize` so the dependency graph (defines/uses edges) round-trips
1515/// through the on-disk cache manifest (spec §4.5).
16#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
1717pub enum TargetId {
1818 Id(String),
1919 CustomId(String),
src/site.rs +5 −5
@@ -9,7 +9,7 @@
99//! `--no-cache` forces a full rebuild; the cache is never a correctness dependency, so a
1010//! full rebuild and an incremental rebuild produce byte-identical output.
1111
12use std::collections::{HashMap, HashSet};
12use std::collections::{BTreeSet, HashMap, HashSet};
1313use std::fs;
1414
1515use anyhow::{Context, Result};
@@ -111,8 +111,8 @@ struct PagePrep {
111111 title: String,
112112 content_hash: ContentHash,
113113 resolved: crate::resolve::ResolvedDoc,
114 used: HashSet<TargetId>,
115 defines: HashSet<TargetId>,
114 used: BTreeSet<TargetId>,
115 defines: BTreeSet<TargetId>,
116116 broken: Vec<TargetId>,
117117 diagnostics: Vec<Diagnostic>,
118118 nav: Vec<NavItem>,
@@ -752,9 +752,9 @@ fn prepare_pages(
752752 .par_iter()
753753 .map(|doc| {
754754 let out = resolve(doc, &symbols);
755 let used: HashSet<TargetId> = out.used_targets.iter().cloned().collect();
755 let used: BTreeSet<TargetId> = out.used_targets.iter().cloned().collect();
756756 let broken: Vec<TargetId> = out.broken.iter().map(|b| b.target.clone()).collect();
757 let defines: HashSet<TargetId> = document_targets(doc).into_iter().collect();
757 let defines: BTreeSet<TargetId> = document_targets(doc).into_iter().collect();
758758
759759 let output = output_path(&doc.source_path, &doc.keywords);
760760
tests/incremental.rs +48
@@ -542,3 +542,51 @@ fn a_previous_build_output_is_not_copied_as_assets() {
542542 "the earlier site must not be nested inside the new one"
543543 );
544544}
545
546/// The cache manifest is a file in the output tree, so it is covered by the same
547/// reproducibility rule as the pages: two builds of the same input write the same bytes.
548/// Serializing the per-file records and the dependency graph out of unordered maps put
549/// them in iteration order, which varies run to run.
550#[test]
551fn cache_manifest_is_byte_identical_across_builds() {
552 let root = tmpdir("manifest-stable");
553 let src = root.join("src");
554 std::fs::create_dir_all(src.join("deep")).unwrap();
555
556 // Enough cross-linked pages, across two directories, that an unordered map comes out
557 // in a different order run to run.
558 for i in 0..24 {
559 let body = format!(
560 "#+TITLE: Page {i}\n#+SLUG: page-{i}\n\n\
561 See [[#anchor-{next}][the next page]].\n\n\
562 * Heading {i}\n:PROPERTIES:\n:CUSTOM_ID: anchor-{i}\n:END:\n",
563 next = (i + 1) % 24
564 );
565 let dir = if i % 3 == 0 { src.join("deep") } else { src.clone() };
566 std::fs::write(dir.join(format!("p{i}.org")), body).unwrap();
567 }
568
569 let build = |out: &Utf8PathBuf| {
570 build_site(
571 &src,
572 out,
573 &BuildOptions {
574 no_cache: true,
575 ..Default::default()
576 },
577 )
578 .unwrap();
579 std::fs::read(manifest_path(out)).unwrap()
580 };
581
582 let first = build(&root.join("first"));
583 for _ in 0..3 {
584 let again = build(&tmpdir("manifest-stable-again").join("out"));
585 // `assert!` rather than `assert_eq!`: a mismatch would otherwise dump both
586 // manifests as byte vectors.
587 assert!(
588 first == again,
589 "the cache manifest must be byte-identical across builds"
590 );
591 }
592}