diff --git a/.forgejo/workflows/publish-image.yaml b/.forgejo/workflows/publish-image.yaml index 024e460..19c0aaf 100644 --- a/.forgejo/workflows/publish-image.yaml +++ b/.forgejo/workflows/publish-image.yaml @@ -10,6 +10,9 @@ on: - ".forgejo/workflows/publish-image.yaml" - "Containerfile" - "deploy/**" + - "docs/architecture/**" + - "docs/adr/**" + - "docs/evidence/*served-publication-history/**" - "publication.json" - "source-inventory.config.json" - "source-inventory.json" @@ -114,11 +117,15 @@ jobs: docker push "${IMAGE}:main" 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}') 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}') + 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 "image_digest=${IMAGE_DIGEST}" echo "publication_manifest_digest=${PUBLICATION_DIGEST}" @@ -133,6 +140,7 @@ jobs: echo "- image: \`${IMAGE}@${IMAGE_DIGEST}\`" echo "- publication manifest: \`${PUBLICATION_DIGEST}\`" echo "- source inventory: \`${SOURCE_INVENTORY_DIGEST}\`" + echo "- source lock: \`${SOURCE_LOCK_DIGEST}\`" echo "- source set: \`${SOURCE_SET_DIGEST}\`" echo echo "Promotion requires an explicit paired digest update in rapp-policy-nexus and railiance-apps." diff --git a/Containerfile b/Containerfile index c13925a..a21e6f3 100644 --- a/Containerfile +++ b/Containerfile @@ -31,6 +31,7 @@ RUN python3 -m unittest discover -s tests -p 'test_*.py' \ --lock /workspace/_sources/source-lock.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" \ + && cp /workspace/_sources/source-lock.json build/source-lock.json \ && python3 tools/verify_release.py build \ && python3 tools/check_currency.py publication.json diff --git a/tests/test_fetch_sources.py b/tests/test_fetch_sources.py index 530aebd..e4d4f2f 100644 --- a/tests/test_fetch_sources.py +++ b/tests/test_fetch_sources.py @@ -2,12 +2,17 @@ from __future__ import annotations from pathlib import Path import sys +import hashlib +import io +import json +import tempfile +from unittest import mock import unittest ROOT = Path(__file__).parents[1] 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): @@ -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__": unittest.main() diff --git a/tests/test_publication.py b/tests/test_publication.py index 769a966..480c7c6 100644 --- a/tests/test_publication.py +++ b/tests/test_publication.py @@ -277,6 +277,14 @@ class PublicationTest(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): manifest = _fixture(root) data = json.loads(manifest.read_text())