From e3fa8243d926efd0b81463dbfc5403cf6e4889f9 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 14 Aug 2026 16:34:43 +0900 Subject: [PATCH] fix(release): complete publication evidence contract --- .github/workflows/publish-image.yml | 4 +++ scripts/image_release.py | 22 +++++++++++++++-- tests/release/test_image_contracts.py | 26 ++++++++++++++++++++ tests/release/test_image_release.py | 35 ++++++++++++++++++++++++--- 4 files changed, 81 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index 6812d30..f9e1a03 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -31,6 +31,9 @@ jobs: ref: ${{ github.sha }} fetch-depth: 0 + - name: Reverify exact checked out commit + run: python scripts/image_release.py verify-checkout --commit "${GITHUB_SHA}" + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: @@ -117,6 +120,7 @@ jobs: image: ${{ steps.publish.outputs.image }} digest: ${{ steps.publish.outputs.digest }} source_commit: ${{ steps.publish.outputs.source_commit }} + version_tag: ${{ steps.publish.outputs.version_tag }} steps: - name: Download tested image artifact uses: actions/download-artifact@v4 diff --git a/scripts/image_release.py b/scripts/image_release.py index 9dafe29..a6816e6 100644 --- a/scripts/image_release.py +++ b/scripts/image_release.py @@ -109,6 +109,14 @@ def validate_publication_trigger(event_name: str, ref: str) -> str: return ref.removeprefix("refs/tags/") +def verify_checkout(commit: str, runner: Runner) -> str: + expected = validate_commit(commit) + actual = runner.run(["git", "rev-parse", "HEAD"]).stdout.strip() + if actual != expected: + raise ValueError("checked out HEAD does not match source commit") + return actual + + def release_tags(commit: str, event_name: str, ref: str) -> list[str]: validate_commit(commit) release = validate_publication_trigger(event_name, ref) @@ -368,6 +376,7 @@ def publish_image( summary_path: Path | None = None, ) -> str: tags = release_tags(commit, event_name, ref) + version_tag = validate_publication_trigger(event_name, ref) verify_checksum(archive, checksum) expected_image_id = read_image_id(image_id) loaded = runner.run(["docker", "load", "--input", str(archive)]) @@ -407,13 +416,17 @@ def publish_image( digest = digests[0] reference = f"{IMAGE}@{digest}" - _append(output_path, f"image={reference}\ndigest={digest}\nsource_commit={commit}\n") + _append( + output_path, + f"image={reference}\ndigest={digest}\nsource_commit={commit}\nversion_tag={version_tag}\n", + ) _append( summary_path, "## Published trex-core image\n\n" f"- Image: `{reference}`\n" f"- Digest: `{digest}`\n" - f"- Source commit: `{commit}`\n", + f"- Source commit: `{commit}`\n" + f"- Version tag: `{version_tag}`\n", ) return digest @@ -426,6 +439,9 @@ def _parser() -> argparse.ArgumentParser: smoke.add_argument("--image", required=True) smoke.add_argument("--timeout", type=int, default=30) + verify_checkout_parser = commands.add_parser("verify-checkout") + verify_checkout_parser.add_argument("--commit", required=True) + scan = commands.add_parser("scan") scan.add_argument("--image", required=True) scan.add_argument("--archive", type=Path, required=True) @@ -461,6 +477,8 @@ def main(argv: Sequence[str] | None = None) -> int: runner = SubprocessRunner() if args.command == "smoke": smoke_image(args.image, runner, args.timeout) + elif args.command == "verify-checkout": + verify_checkout(args.commit, runner) elif args.command == "scan": scan_image(args.image, args.archive, runner) elif args.command == "checksum": diff --git a/tests/release/test_image_contracts.py b/tests/release/test_image_contracts.py index becc559..f8ee506 100644 --- a/tests/release/test_image_contracts.py +++ b/tests/release/test_image_contracts.py @@ -40,6 +40,22 @@ def test_workflow_rejects_non_strict_version_events_before_checkout_and_build(): assert _step_index(steps, guard["name"]) < _step_index(steps, "Build source image once") +def test_workflow_reverifies_exact_checked_out_commit_before_repository_or_registry_work(): + workflow = _workflow() + verify_steps = workflow["jobs"]["verify"]["steps"] + publish_steps = workflow["jobs"]["publish"]["steps"] + checkout_index = _step_index(verify_steps, "Check out exact source commit") + reverify_index = _step_index(verify_steps, "Reverify exact checked out commit") + + assert checkout_index < reverify_index < _step_index(verify_steps, "Install repository checks") + assert verify_steps[reverify_index]["run"] == ( + 'python scripts/image_release.py verify-checkout --commit "${GITHUB_SHA}"' + ) + assert reverify_index < _step_index(verify_steps, "Build source image once") + assert all("REGISTRY_TOKEN" not in str(step) for step in verify_steps[: reverify_index + 1]) + assert _step_index(publish_steps, "Verify, load, and publish without rebuilding") > 0 + + def test_publish_is_gated_and_has_the_only_package_write_permission(): workflow = _workflow() verify = workflow["jobs"]["verify"] @@ -51,6 +67,16 @@ def test_publish_is_gated_and_has_the_only_package_write_permission(): assert "packages" not in verify["permissions"] +def test_publish_job_exposes_selected_version_tag_with_other_release_evidence(): + outputs = _workflow()["jobs"]["publish"]["outputs"] + assert outputs == { + "image": "${{ steps.publish.outputs.image }}", + "digest": "${{ steps.publish.outputs.digest }}", + "source_commit": "${{ steps.publish.outputs.source_commit }}", + "version_tag": "${{ steps.publish.outputs.version_tag }}", + } + + def test_verify_canary_build_smoke_export_scan_checksum_upload_contract(): steps = _workflow()["jobs"]["verify"]["steps"] names = [step["name"] for step in steps] diff --git a/tests/release/test_image_release.py b/tests/release/test_image_release.py index e898175..6162845 100644 --- a/tests/release/test_image_release.py +++ b/tests/release/test_image_release.py @@ -40,6 +40,8 @@ class RecordingRunner: self.commands.append((command, input_text, timeout)) if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on): raise subprocess.CalledProcessError(1, command) + if command == ["git", "rev-parse", "HEAD"]: + return image_release.Result(self.revision + "\n") if command[:2] == ["docker", "load"]: return image_release.Result(f"Loaded image: {self.loaded_image}\n") if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command: @@ -110,6 +112,21 @@ def test_invalid_source_commit_is_rejected(commit): image_release.release_tags(commit, "push", "refs/tags/v1.2.3") +def test_verify_checkout_requires_exact_git_head_match(): + runner = RecordingRunner() + + assert image_release.verify_checkout(COMMIT, runner) == COMMIT + assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)] + + +def test_verify_checkout_mismatch_fails_closed(): + runner = RecordingRunner(revision="c" * 40) + + with pytest.raises(ValueError, match="checked out HEAD does not match source commit"): + image_release.verify_checkout(COMMIT, runner) + assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)] + + def test_smoke_uses_exact_isolated_probes_and_cleans_containers(): runner = RecordingRunner() image_release.smoke_image("candidate", runner, timeout=17, migration_dir=MIGRATION_DIR) @@ -348,7 +365,7 @@ def test_record_image_id_writes_strict_inspected_id(tmp_path): ] -def test_valid_publish_records_digest_only_after_push(tmp_path): +def test_valid_publish_records_exact_release_evidence_only_after_push(tmp_path): archive, checksum, image_id = _artifact(tmp_path) output = tmp_path / "output" summary = tmp_path / "summary" @@ -373,9 +390,19 @@ def test_valid_publish_records_digest_only_after_push(tmp_path): assert commands.index(["docker", "load"]) < commands.index(["docker", "login"]) assert commands.count(["docker", "push"]) == 2 assert digest == DIGEST - assert f"digest={DIGEST}" in output.read_text() - assert COMMIT in output.read_text() - assert f"{image_release.IMAGE}@{DIGEST}" in summary.read_text() + assert output.read_text() == ( + f"image={image_release.IMAGE}@{DIGEST}\n" + f"digest={DIGEST}\n" + f"source_commit={COMMIT}\n" + "version_tag=v2.0.0\n" + ) + assert summary.read_text() == ( + "## Published trex-core image\n\n" + f"- Image: `{image_release.IMAGE}@{DIGEST}`\n" + f"- Digest: `{DIGEST}`\n" + f"- Source commit: `{COMMIT}`\n" + "- Version tag: `v2.0.0`\n" + ) @pytest.mark.parametrize("bad_digest", ["", "sha256:" + "b" * 63, "sha512:" + "b" * 64])