Isolate publication fixtures and bind CI receipts to immutable image

Assistant: codex
Assistant-Model: gpt-6.1-sol
Assistant-Session: 01a126e1-76f8-7643-8cf3-e93336539ade
This commit is contained in:
tegwick 2026-10-11 02:28:04 +02:00
parent 7c51a7b185
commit bb193b2e87
4 changed files with 57 additions and 3 deletions

View file

@ -10,6 +10,9 @@ on:
- ".forgejo/workflows/publish-image.yaml" - ".forgejo/workflows/publish-image.yaml"
- "Containerfile" - "Containerfile"
- "deploy/**" - "deploy/**"
- "docs/architecture/**"
- "docs/adr/**"
- "docs/evidence/*served-publication-history/**"
- "publication.json" - "publication.json"
- "source-inventory.config.json" - "source-inventory.config.json"
- "source-inventory.json" - "source-inventory.json"
@ -114,11 +117,15 @@ jobs:
docker push "${IMAGE}:main" docker push "${IMAGE}:main"
PUBLICATION_DIGEST=$(docker run --rm --entrypoint sha256sum \ PUBLICATION_DIGEST=$(docker run --rm --entrypoint sha256sum \
"${IMAGE}:${SOURCE_TAG}" /usr/share/nginx/html/publication-manifest.json \ "${IMAGE}@${IMAGE_DIGEST}" /usr/share/nginx/html/publication-manifest.json \
| awk '{print $1}') | awk '{print $1}')
SOURCE_INVENTORY_DIGEST=$(docker run --rm --entrypoint sha256sum \ SOURCE_INVENTORY_DIGEST=$(docker run --rm --entrypoint sha256sum \
"${IMAGE}:${SOURCE_TAG}" /usr/share/nginx/html/source-inventory.json \ "${IMAGE}@${IMAGE_DIGEST}" /usr/share/nginx/html/source-inventory.json \
| awk '{print $1}') | awk '{print $1}')
SOURCE_LOCK_DIGEST=$(docker run --rm --entrypoint sha256sum \
"${IMAGE}@${IMAGE_DIGEST}" /usr/share/nginx/html/source-lock.json \
| awk '{print $1}')
echo "source_lock_digest=${SOURCE_LOCK_DIGEST}"
echo "published=${IMAGE}:${SOURCE_TAG}" echo "published=${IMAGE}:${SOURCE_TAG}"
echo "image_digest=${IMAGE_DIGEST}" echo "image_digest=${IMAGE_DIGEST}"
echo "publication_manifest_digest=${PUBLICATION_DIGEST}" echo "publication_manifest_digest=${PUBLICATION_DIGEST}"
@ -133,6 +140,7 @@ jobs:
echo "- image: \`${IMAGE}@${IMAGE_DIGEST}\`" echo "- image: \`${IMAGE}@${IMAGE_DIGEST}\`"
echo "- publication manifest: \`${PUBLICATION_DIGEST}\`" echo "- publication manifest: \`${PUBLICATION_DIGEST}\`"
echo "- source inventory: \`${SOURCE_INVENTORY_DIGEST}\`" echo "- source inventory: \`${SOURCE_INVENTORY_DIGEST}\`"
echo "- source lock: \`${SOURCE_LOCK_DIGEST}\`"
echo "- source set: \`${SOURCE_SET_DIGEST}\`" echo "- source set: \`${SOURCE_SET_DIGEST}\`"
echo echo
echo "Promotion requires an explicit paired digest update in rapp-policy-nexus and railiance-apps." echo "Promotion requires an explicit paired digest update in rapp-policy-nexus and railiance-apps."

View file

@ -31,6 +31,7 @@ RUN python3 -m unittest discover -s tests -p 'test_*.py' \
--lock /workspace/_sources/source-lock.json \ --lock /workspace/_sources/source-lock.json \
--report build/source-inventory.json \ --report build/source-inventory.json \
&& test "$(python3 -c 'import json; print(json.load(open("/workspace/_sources/source-lock.json"))["source_set_digest"])')" = "$SOURCE_SET_DIGEST" \ && test "$(python3 -c 'import json; print(json.load(open("/workspace/_sources/source-lock.json"))["source_set_digest"])')" = "$SOURCE_SET_DIGEST" \
&& cp /workspace/_sources/source-lock.json build/source-lock.json \
&& python3 tools/verify_release.py build \ && python3 tools/verify_release.py build \
&& python3 tools/check_currency.py publication.json && python3 tools/check_currency.py publication.json

View file

@ -2,12 +2,17 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import sys import sys
import hashlib
import io
import json
import tempfile
from unittest import mock
import unittest import unittest
ROOT = Path(__file__).parents[1] ROOT = Path(__file__).parents[1]
sys.path.insert(0, str(ROOT / "tools")) sys.path.insert(0, str(ROOT / "tools"))
from fetch_sources import _archive_url, _request from fetch_sources import _archive_url, _request, fetch
class FetchSourcesAuthenticationTest(unittest.TestCase): class FetchSourcesAuthenticationTest(unittest.TestCase):
@ -53,5 +58,37 @@ class FetchSourcesAuthenticationTest(unittest.TestCase):
) )
def test_fetch_locks_workflow_self_sha_and_exact_resolved_upstream_archive(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = root / "config.json"
config.write_text(json.dumps({"schema_version": 1, "repositories": {
"policy-nexus": {"local": True},
"owner": {"remote": "https://forgejo.coulomb.social/coulomb/owner.git", "branch": "main"},
}}))
self_sha, owner_sha = "b" * 40, "a" * 40
with mock.patch("fetch_sources._revision", return_value=owner_sha) as resolve, \
mock.patch("fetch_sources.urllib.request.urlopen", return_value=io.BytesIO(b"archive")) as opened, \
mock.patch("fetch_sources._extract"):
lock = fetch(config, root / "sources", self_sha)
self.assertEqual(lock["repositories"]["policy-nexus"]["revision"], self_sha)
self.assertEqual(lock["repositories"]["owner"]["revision"], owner_sha)
self.assertEqual(resolve.call_count, 1)
self.assertEqual(opened.call_args.args[0].full_url,
"https://forgejo.coulomb.social/coulomb/owner/archive/" + owner_sha + ".tar.gz")
canonical = json.dumps({"owner": owner_sha, "policy-nexus": self_sha},
sort_keys=True, separators=(",", ":")).encode()
self.assertEqual(lock["source_set_digest"], hashlib.sha256(canonical).hexdigest())
self.assertEqual(json.loads((root / "sources/source-lock.json").read_text()), lock)
def test_mutable_self_reference_refused_before_fetch(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
with self.assertRaisesRegex(ValueError, "clean 40-hex"), \
mock.patch("fetch_sources.urllib.request.urlopen") as opened:
fetch(root / "config.json", root / "sources", "main")
opened.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -277,6 +277,14 @@ class PublicationTest(unittest.TestCase):
class HistoricalRevisionTest(unittest.TestCase): class HistoricalRevisionTest(unittest.TestCase):
def setUp(self) -> None:
# Fixtures are independent repositories, not members of the CI source lock.
self.source_root_env = mock.patch.dict(
os.environ, {"POLICY_NEXUS_SOURCE_ROOT": ""}, clear=False
)
self.source_root_env.start()
self.addCleanup(self.source_root_env.stop)
def prepare(self, root): def prepare(self, root):
manifest = _fixture(root) manifest = _fixture(root)
data = json.loads(manifest.read_text()) data = json.loads(manifest.read_text())