From f0bbf76c05bcad773558e43156c72c8b357630ba Mon Sep 17 00:00:00 2001 From: Steve Brown Date: Fri, 23 Oct 2026 12:18:40 -0400 Subject: [PATCH] fix(release): report pendant publisher admission before readback --- .github/workflows/release.yml | 7 ++- docs/knowledge/README.md | 14 ++++- scripts/test-repository-share-publication.py | 63 +++++++++++++++++++- scripts/verify-repository-share.py | 44 +++++++++++--- 4 files changed, 117 insertions(+), 11 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c0b2b289..749326f6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -660,7 +660,7 @@ jobs: dry-run: 'false' repository-pendant: - name: Verify repository pendant + name: Repository pendant admission and readback needs: [release-please, release] if: always() && needs.release.result == 'success' runs-on: ubuntu-22.04 @@ -670,11 +670,16 @@ jobs: env: VERSION: ${{ inputs.version || (startsWith(github.ref, 'refs/tags/') && github.ref_name) || needs.release-please.outputs.tag_name }} GH_TOKEN: ${{ github.token }} + TRUSTED_REPOSITORY_SHARE_ENABLED: ${{ vars.TRUSTED_REPOSITORY_SHARE_ENABLED }} steps: - name: Checkout exact published source uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: refs/tags/${{ env.VERSION }} fetch-depth: 0 + - name: Report trusted publisher admission + id: publisher + run: python3 scripts/verify-repository-share.py --admission - name: Require repository-owned trusted-host pendant + if: steps.publisher.outputs.admitted == 'true' run: python3 scripts/verify-repository-share.py "$VERSION" "$(git rev-parse HEAD)" diff --git a/docs/knowledge/README.md b/docs/knowledge/README.md index a7ac3df3..1aeed410 100644 --- a/docs/knowledge/README.md +++ b/docs/knowledge/README.md @@ -21,8 +21,18 @@ record may produce derived identity through the engine's existing apply path. The current source profile deliberately does not import arbitrary nonempty mapping data until an actual curated artifact and its transport are reviewed. -The copied release workflow verifies the three public assets only after the -ordinary release finishes. The managed trusted host runs this repository's own +The release workflow always reports publisher admission after the ordinary +release finishes. Only the exact repository variable +`TRUSTED_REPOSITORY_SHARE_ENABLED=true` admits the bounded public readback. +Unset, empty or `false` reports `NOT_ADMITTED` without downloading or waiting; +malformed values fail. This is not a verified pendant or a publication success. +When admitted, absent assets still exhaust the bounded wait and fail, and the +archive, checksum and exact source/tag provenance checks remain mandatory. + +The variable attests an independently configured trusted producer; it does not +enable one. Keep it unset until the reviewed producer configuration and pins +are actually installed. No publication, credential or admission change is made +by this source correction. The managed trusted host runs this repository's own publisher, keeps its publication policy private, pins both native engine executables and the indexer, and refuses retained uncertain upload outcomes. Default configuration still publishes only the existing Quipu corpus. Enabling diff --git a/scripts/test-repository-share-publication.py b/scripts/test-repository-share-publication.py index 05506339..2e5c0717 100644 --- a/scripts/test-repository-share-publication.py +++ b/scripts/test-repository-share-publication.py @@ -249,11 +249,72 @@ def run(args, **kwargs): with self.assertRaisesRegex(ValueError, "bounded"): verifier.wait( - "v1.2.3", self.revision, attempts=2, pause=pauses.append, run=run + "v1.2.3", self.revision, attempts=2, pause=pauses.append, run=run, admitted=True ) self.assertEqual(len(calls), 2) self.assertEqual(pauses, [30]) + def test_unadmitted_release_makes_no_read_or_wait(self): + def forbidden(*args, **kwargs): + self.fail("unadmitted release attempted a read or pause") + + result = verifier.wait("v1.2.3", self.revision, run=forbidden, pause=forbidden) + self.assertEqual(result, { + "status": "NOT_ADMITTED", "source_repository": "scbrown/bobbin", + "release_tag": "v1.2.3", "source_revision": self.revision, + }) + with self.assertRaisesRegex(ValueError, "invalid release identity"): + verifier.wait("bad-tag", self.revision, run=forbidden) + with self.assertRaisesRegex(ValueError, "boolean"): + verifier.wait("v1.2.3", self.revision, admitted="false", run=forbidden) + + def test_admitted_release_still_verifies_downloaded_bytes(self): + self.prepare() + calls = [] + + def download(args, **kwargs): + calls.append(args) + folder = Path(args[args.index("--dir") + 1]) + for source in (self.archive, self.proof_path, Path(str(self.archive) + ".sha256")): + shutil.copyfile(source, folder / source.name) + return subprocess.CompletedProcess(args, 0) + + result = verifier.wait("v1.2.3", self.revision, attempts=1, run=download, admitted=True) + self.assertEqual(result["status"], "VERIFIED") + self.assertEqual(result["source_revision"], self.revision) + self.assertEqual(len(calls), 1) + self.archive.with_name(self.archive.name + ".sha256").write_text("wrong") + with self.assertRaisesRegex(ValueError, "checksum"): + verifier.wait("v1.2.3", self.revision, attempts=1, run=download, admitted=True) + + def test_admission_cli_reports_output_and_refuses_malformed_values(self): + for value, status, output in [(None, "NOT_ADMITTED", "false"), ("", "NOT_ADMITTED", "false"), + ("false", "NOT_ADMITTED", "false"), ("true", "ADMITTED", "true")]: + with self.subTest(value=value): + env = dict(os.environ) + env.pop("TRUSTED_REPOSITORY_SHARE_ENABLED", None) + if value is not None: + env["TRUSTED_REPOSITORY_SHARE_ENABLED"] = value + target = self.root / "github-output" + target.write_text("existing=retained\n") + env["GITHUB_OUTPUT"] = str(target) + result = subprocess.run([sys.executable, str(ROOT / "verify-repository-share.py"), "--admission"], + env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(json.loads(result.stdout)["status"], status) + self.assertEqual(target.read_text(), "existing=retained\nadmitted=" + output + "\n") + for value in ("TRUE", "1", " false "): + with self.subTest(value=value), self.assertRaisesRegex(ValueError, "admission"): + verifier.publisher_admitted(value) + + def test_release_workflow_binds_admission_before_readback(self): + workflow = (ROOT.parent / ".github/workflows/release.yml").read_text() + job = workflow.split(" repository-pendant:\n", 1)[1] + self.assertIn("TRUSTED_REPOSITORY_SHARE_ENABLED: ${{ vars.TRUSTED_REPOSITORY_SHARE_ENABLED }}", job) + self.assertIn("id: publisher\n run: python3 scripts/verify-repository-share.py --admission", job) + self.assertIn("if: steps.publisher.outputs.admitted == 'true'", job) + self.assertLess(job.index("--admission"), job.index("Require repository-owned trusted-host pendant")) + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-repository-share.py b/scripts/verify-repository-share.py index 353b02e2..4be4356e 100644 --- a/scripts/verify-repository-share.py +++ b/scripts/verify-repository-share.py @@ -4,6 +4,7 @@ import hashlib import importlib.util import json +import os import re import subprocess import sys @@ -67,11 +68,29 @@ def verify(folder, tag, revision): return expected -def wait(tag, revision, attempts=80, pause=time.sleep, run=subprocess.run): +def publisher_admitted(value): + """Release-side attestation; this does not enable the trusted producer.""" + if value in (None, "", "false"): + return False + if value == "true": + return True + raise ValueError("publisher admission must be true, false or unset") + + +def wait(tag, revision, attempts=80, pause=time.sleep, run=subprocess.run, *, admitted=False): if not re.fullmatch( r"v\d+\.\d+\.\d+(?:[.-][A-Za-z0-9.-]+)?", tag ) or not re.fullmatch("[0-9a-f]{40}", revision): raise ValueError("invalid release identity before lookup") + if type(admitted) is not bool: + raise ValueError("publisher admission must be a boolean") + if not admitted: + return { + "status": "NOT_ADMITTED", + "source_repository": "scbrown/bobbin", + "release_tag": tag, + "source_revision": revision, + } stem = "bobbin-" + tag + "-repository.pendant.tar.gz" last_read = "not attempted" for number in range(attempts): @@ -101,7 +120,7 @@ def wait(tag, revision, attempts=80, pause=time.sleep, run=subprocess.run): ) last_read = "exit=" + str(result.returncode) if result.returncode == 0: - return verify(folder, tag, revision) + return {"status": "VERIFIED", **verify(folder, tag, revision)} except subprocess.TimeoutExpired: last_read = "read timeout" if number + 1 < attempts: @@ -112,9 +131,20 @@ def wait(tag, revision, attempts=80, pause=time.sleep, run=subprocess.run): ) +def main(args): + admitted = publisher_admitted(os.environ.get("TRUSTED_REPOSITORY_SHARE_ENABLED")) + if args == ["--admission"]: + if output := os.environ.get("GITHUB_OUTPUT"): + with Path(output).open("a") as handle: + handle.write("admitted=" + str(admitted).lower() + "\n") + return { + "status": "ADMITTED" if admitted else "NOT_ADMITTED", + "source_repository": "scbrown/bobbin", + } + if len(args) != 2: + raise SystemExit("usage: verify-repository-share.py --admission | ") + return wait(args[0], args[1], admitted=admitted) + + if __name__ == "__main__": - if len(sys.argv) != 3: - raise SystemExit( - "usage: verify-repository-share.py " - ) - print(json.dumps(wait(sys.argv[1], sys.argv[2]))) + print(json.dumps(main(sys.argv[1:])))