fix(release): complete publication evidence contract
This commit is contained in:
@@ -31,6 +31,9 @@ jobs:
|
|||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
fetch-depth: 0
|
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
|
- name: Set up Python 3.12
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -117,6 +120,7 @@ jobs:
|
|||||||
image: ${{ steps.publish.outputs.image }}
|
image: ${{ steps.publish.outputs.image }}
|
||||||
digest: ${{ steps.publish.outputs.digest }}
|
digest: ${{ steps.publish.outputs.digest }}
|
||||||
source_commit: ${{ steps.publish.outputs.source_commit }}
|
source_commit: ${{ steps.publish.outputs.source_commit }}
|
||||||
|
version_tag: ${{ steps.publish.outputs.version_tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download tested image artifact
|
- name: Download tested image artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
|
|||||||
@@ -109,6 +109,14 @@ def validate_publication_trigger(event_name: str, ref: str) -> str:
|
|||||||
return ref.removeprefix("refs/tags/")
|
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]:
|
def release_tags(commit: str, event_name: str, ref: str) -> list[str]:
|
||||||
validate_commit(commit)
|
validate_commit(commit)
|
||||||
release = validate_publication_trigger(event_name, ref)
|
release = validate_publication_trigger(event_name, ref)
|
||||||
@@ -368,6 +376,7 @@ def publish_image(
|
|||||||
summary_path: Path | None = None,
|
summary_path: Path | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
tags = release_tags(commit, event_name, ref)
|
tags = release_tags(commit, event_name, ref)
|
||||||
|
version_tag = validate_publication_trigger(event_name, ref)
|
||||||
verify_checksum(archive, checksum)
|
verify_checksum(archive, checksum)
|
||||||
expected_image_id = read_image_id(image_id)
|
expected_image_id = read_image_id(image_id)
|
||||||
loaded = runner.run(["docker", "load", "--input", str(archive)])
|
loaded = runner.run(["docker", "load", "--input", str(archive)])
|
||||||
@@ -407,13 +416,17 @@ def publish_image(
|
|||||||
|
|
||||||
digest = digests[0]
|
digest = digests[0]
|
||||||
reference = f"{IMAGE}@{digest}"
|
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(
|
_append(
|
||||||
summary_path,
|
summary_path,
|
||||||
"## Published trex-core image\n\n"
|
"## Published trex-core image\n\n"
|
||||||
f"- Image: `{reference}`\n"
|
f"- Image: `{reference}`\n"
|
||||||
f"- Digest: `{digest}`\n"
|
f"- Digest: `{digest}`\n"
|
||||||
f"- Source commit: `{commit}`\n",
|
f"- Source commit: `{commit}`\n"
|
||||||
|
f"- Version tag: `{version_tag}`\n",
|
||||||
)
|
)
|
||||||
return digest
|
return digest
|
||||||
|
|
||||||
@@ -426,6 +439,9 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
smoke.add_argument("--image", required=True)
|
smoke.add_argument("--image", required=True)
|
||||||
smoke.add_argument("--timeout", type=int, default=30)
|
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 = commands.add_parser("scan")
|
||||||
scan.add_argument("--image", required=True)
|
scan.add_argument("--image", required=True)
|
||||||
scan.add_argument("--archive", type=Path, required=True)
|
scan.add_argument("--archive", type=Path, required=True)
|
||||||
@@ -461,6 +477,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
runner = SubprocessRunner()
|
runner = SubprocessRunner()
|
||||||
if args.command == "smoke":
|
if args.command == "smoke":
|
||||||
smoke_image(args.image, runner, args.timeout)
|
smoke_image(args.image, runner, args.timeout)
|
||||||
|
elif args.command == "verify-checkout":
|
||||||
|
verify_checkout(args.commit, runner)
|
||||||
elif args.command == "scan":
|
elif args.command == "scan":
|
||||||
scan_image(args.image, args.archive, runner)
|
scan_image(args.image, args.archive, runner)
|
||||||
elif args.command == "checksum":
|
elif args.command == "checksum":
|
||||||
|
|||||||
@@ -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")
|
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():
|
def test_publish_is_gated_and_has_the_only_package_write_permission():
|
||||||
workflow = _workflow()
|
workflow = _workflow()
|
||||||
verify = workflow["jobs"]["verify"]
|
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"]
|
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():
|
def test_verify_canary_build_smoke_export_scan_checksum_upload_contract():
|
||||||
steps = _workflow()["jobs"]["verify"]["steps"]
|
steps = _workflow()["jobs"]["verify"]["steps"]
|
||||||
names = [step["name"] for step in steps]
|
names = [step["name"] for step in steps]
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class RecordingRunner:
|
|||||||
self.commands.append((command, input_text, timeout))
|
self.commands.append((command, input_text, timeout))
|
||||||
if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on):
|
if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on):
|
||||||
raise subprocess.CalledProcessError(1, command)
|
raise subprocess.CalledProcessError(1, command)
|
||||||
|
if command == ["git", "rev-parse", "HEAD"]:
|
||||||
|
return image_release.Result(self.revision + "\n")
|
||||||
if command[:2] == ["docker", "load"]:
|
if command[:2] == ["docker", "load"]:
|
||||||
return image_release.Result(f"Loaded image: {self.loaded_image}\n")
|
return image_release.Result(f"Loaded image: {self.loaded_image}\n")
|
||||||
if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command:
|
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")
|
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():
|
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
|
||||||
runner = RecordingRunner()
|
runner = RecordingRunner()
|
||||||
image_release.smoke_image("candidate", runner, timeout=17, migration_dir=MIGRATION_DIR)
|
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)
|
archive, checksum, image_id = _artifact(tmp_path)
|
||||||
output = tmp_path / "output"
|
output = tmp_path / "output"
|
||||||
summary = tmp_path / "summary"
|
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.index(["docker", "load"]) < commands.index(["docker", "login"])
|
||||||
assert commands.count(["docker", "push"]) == 2
|
assert commands.count(["docker", "push"]) == 2
|
||||||
assert digest == DIGEST
|
assert digest == DIGEST
|
||||||
assert f"digest={DIGEST}" in output.read_text()
|
assert output.read_text() == (
|
||||||
assert COMMIT in output.read_text()
|
f"image={image_release.IMAGE}@{DIGEST}\n"
|
||||||
assert f"{image_release.IMAGE}@{DIGEST}" in summary.read_text()
|
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])
|
@pytest.mark.parametrize("bad_digest", ["", "sha256:" + "b" * 63, "sha512:" + "b" * 64])
|
||||||
|
|||||||
Reference in New Issue
Block a user