Preserve served immutable revision across unchanged source rebuilds
Some checks failed
Build and publish policy-nexus image / build-and-push (push) Failing after 1m41s
Some checks failed
Build and publish policy-nexus image / build-and-push (push) Failing after 1m41s
Assistant: codex Assistant-Model: gpt-6.1-sol Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
parent
5c41564f04
commit
6d2c7eef77
3 changed files with 66 additions and 6 deletions
|
|
@ -573,7 +573,17 @@
|
||||||
"revision_path": "standards/security-layer-model/v0.7/revisions/{revision}/index.html",
|
"revision_path": "standards/security-layer-model/v0.7/revisions/{revision}/index.html",
|
||||||
"review_interval": "3m",
|
"review_interval": "3m",
|
||||||
"lifecycle": "superseded",
|
"lifecycle": "superseded",
|
||||||
"successor": "/standards/security-layer-model/v0.8/"
|
"successor": "/standards/security-layer-model/v0.8/",
|
||||||
|
"historical_revisions": [
|
||||||
|
{
|
||||||
|
"revision": "0.7",
|
||||||
|
"source_path": "docs/evidence/2026-10-11-layer-v07-immutable-publication/security-layer-model-v0.7.md",
|
||||||
|
"source_digest": "8155e7b123be1e84377d8278525b1bad8961007b6bb8ff012dd01ba24ff8065b",
|
||||||
|
"source_revision": "ce198fc2905687ea90a2892346b6860281ac87f8",
|
||||||
|
"rendered_page_path": "docs/evidence/2026-10-11-layer-v07-immutable-publication/security-layer-model-v0.7.html",
|
||||||
|
"rendered_page_digest": "7d7a38a8c3b9fff65b46280fcd5723eff77dc9d0784f871bb35f718bc4e8c0e7"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "netkingdom-security-layer-model-v0.8",
|
"id": "netkingdom-security-layer-model-v0.8",
|
||||||
|
|
|
||||||
|
|
@ -321,6 +321,53 @@ class HistoricalRevisionTest(unittest.TestCase):
|
||||||
self.assertEqual("Original Founder", meta["decided_by"])
|
self.assertEqual("Original Founder", meta["decided_by"])
|
||||||
self.assertEqual("2026-08-18", meta["date"])
|
self.assertEqual("2026-08-18", meta["date"])
|
||||||
|
|
||||||
|
def test_same_current_frozen_render_survives_new_head_and_superseded_notice(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
manifest, source, page, original = self.prepare(root)
|
||||||
|
source.write_bytes(page.with_suffix(".md").read_bytes())
|
||||||
|
data = json.loads(manifest.read_text())
|
||||||
|
data["documents"][0].update(lifecycle="superseded", successor="/standards/example/v2/")
|
||||||
|
manifest.write_text(json.dumps(data))
|
||||||
|
output = root / "clean"
|
||||||
|
with mock.patch.object(build_site, "_source_revision", return_value="b" * 40):
|
||||||
|
build_site.build(manifest, output, as_of=dt.date(2026, 8, 18))
|
||||||
|
frozen = output / "standards/example/v1/revisions/draft-1/index.html"
|
||||||
|
current = output / "standards/example/v1/index.html"
|
||||||
|
self.assertEqual(original, frozen.read_bytes())
|
||||||
|
self.assertIn('policy-source-revision" content="' + "a" * 40, frozen.read_text())
|
||||||
|
self.assertNotIn("Superseded.", frozen.read_text())
|
||||||
|
self.assertIn("Superseded.", current.read_text())
|
||||||
|
self.assertIn('policy-source-revision" content="' + "b" * 40, current.read_text())
|
||||||
|
self.assertEqual(source.read_bytes(), page.with_suffix(".md").read_bytes())
|
||||||
|
|
||||||
|
def test_same_current_frozen_render_rejects_current_source_change(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
manifest, source, page, _original = self.prepare(root)
|
||||||
|
source.write_bytes(page.with_suffix(".md").read_bytes() + b"Changed current bytes.")
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "same-current historical source differs"):
|
||||||
|
build_site.build(manifest, root / "clean")
|
||||||
|
self.assertFalse((root / "clean").exists())
|
||||||
|
|
||||||
|
def test_frozen_current_revision_accepts_source_version_frontmatter(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
manifest, source, page, original = self.prepare(root)
|
||||||
|
history = page.with_suffix(".md")
|
||||||
|
history.write_bytes(history.read_bytes().replace(b'revision: "draft-1"', b'version: "draft-1"'))
|
||||||
|
source.write_bytes(history.read_bytes())
|
||||||
|
data = json.loads(manifest.read_text())
|
||||||
|
historical = data["documents"][0]["historical_revisions"][0]
|
||||||
|
previous_digest = historical["source_digest"]
|
||||||
|
historical["source_digest"] = hashlib.sha256(history.read_bytes()).hexdigest()
|
||||||
|
original = original.replace(previous_digest.encode(), historical["source_digest"].encode())
|
||||||
|
page.write_bytes(original)
|
||||||
|
historical["rendered_page_digest"] = hashlib.sha256(original).hexdigest()
|
||||||
|
manifest.write_text(json.dumps(data))
|
||||||
|
build_site.build(manifest, root / "clean")
|
||||||
|
self.assertEqual(original, (root / "clean/standards/example/v1/revisions/draft-1/index.html").read_bytes())
|
||||||
|
|
||||||
def test_changed_historical_page_fails_before_replacing_output(self):
|
def test_changed_historical_page_fails_before_replacing_output(self):
|
||||||
with tempfile.TemporaryDirectory() as directory:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
root = Path(directory)
|
root = Path(directory)
|
||||||
|
|
|
||||||
|
|
@ -123,12 +123,11 @@ def load_manifest(path: Path) -> dict[str, Any]:
|
||||||
|
|
||||||
def _restore_historical_revisions(
|
def _restore_historical_revisions(
|
||||||
document: dict[str, Any], repo: Path, output: Path, current_revision: str,
|
document: dict[str, Any], repo: Path, output: Path, current_revision: str,
|
||||||
|
current_source_digest: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Retain explicitly pinned served revision bytes even in a clean build."""
|
"""Retain explicitly pinned served revision bytes even in a clean build."""
|
||||||
for historical in document.get("historical_revisions", []):
|
for historical in document.get("historical_revisions", []):
|
||||||
revision = historical["revision"]
|
revision = historical["revision"]
|
||||||
if revision == current_revision:
|
|
||||||
raise ValueError("historical revision must differ from current revision")
|
|
||||||
source = (repo / historical["source_path"]).resolve()
|
source = (repo / historical["source_path"]).resolve()
|
||||||
page = (repo / historical["rendered_page_path"]).resolve()
|
page = (repo / historical["rendered_page_path"]).resolve()
|
||||||
for artifact in (source, page):
|
for artifact in (source, page):
|
||||||
|
|
@ -137,10 +136,14 @@ def _restore_historical_revisions(
|
||||||
source_bytes, page_bytes = source.read_bytes(), page.read_bytes()
|
source_bytes, page_bytes = source.read_bytes(), page.read_bytes()
|
||||||
if hashlib.sha256(source_bytes).hexdigest() != historical["source_digest"]:
|
if hashlib.sha256(source_bytes).hexdigest() != historical["source_digest"]:
|
||||||
raise RuntimeError("historical source digest mismatch")
|
raise RuntimeError("historical source digest mismatch")
|
||||||
|
# A served revision can still be the document's current content revision.
|
||||||
|
# Freeze its old render/provenance only if today's source bytes are exact.
|
||||||
|
if revision == current_revision and historical["source_digest"] != current_source_digest:
|
||||||
|
raise RuntimeError("same-current historical source differs from current source")
|
||||||
if hashlib.sha256(page_bytes).hexdigest() != historical["rendered_page_digest"]:
|
if hashlib.sha256(page_bytes).hexdigest() != historical["rendered_page_digest"]:
|
||||||
raise RuntimeError("historical rendered page digest mismatch")
|
raise RuntimeError("historical rendered page digest mismatch")
|
||||||
meta, _ = split_frontmatter(source_bytes.decode("utf-8"))
|
meta, _ = split_frontmatter(source_bytes.decode("utf-8"))
|
||||||
if meta.get("id") != document["id"] or meta.get("revision") != revision:
|
if meta.get("id") != document["id"] or (meta.get("revision") or meta.get("version")) != revision:
|
||||||
raise ValueError("historical source id/revision mismatch")
|
raise ValueError("historical source id/revision mismatch")
|
||||||
page_text = page_bytes.decode("utf-8")
|
page_text = page_bytes.decode("utf-8")
|
||||||
source_revision = SOURCE_REVISION.search(page_text)
|
source_revision = SOURCE_REVISION.search(page_text)
|
||||||
|
|
@ -254,9 +257,9 @@ def build(
|
||||||
revision = meta.get("revision") or meta.get("version")
|
revision = meta.get("revision") or meta.get("version")
|
||||||
if not revision:
|
if not revision:
|
||||||
raise ValueError(f"{source}: revision or version is required")
|
raise ValueError(f"{source}: revision or version is required")
|
||||||
_restore_historical_revisions(document, repo, temporary, revision)
|
|
||||||
source_revision = _source_revision(repo, source)
|
|
||||||
source_digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
source_digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||||
|
_restore_historical_revisions(document, repo, temporary, revision, source_digest)
|
||||||
|
source_revision = _source_revision(repo, source)
|
||||||
reviewed = meta.get("last_reviewed") or meta.get("updated")
|
reviewed = meta.get("last_reviewed") or meta.get("updated")
|
||||||
interval = document.get("review_interval") or meta.get("review_interval")
|
interval = document.get("review_interval") or meta.get("review_interval")
|
||||||
if not reviewed or not interval:
|
if not reviewed or not interval:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue