feat(release): require versioned locked publication

This commit is contained in:
Hermes Agent
2026-08-14 16:30:16 +09:00
parent 1a2b500722
commit cb8fcee226
6 changed files with 146 additions and 33 deletions
+27 -3
View File
@@ -17,10 +17,27 @@ def _step_index(steps, name):
return next(index for index, step in enumerate(steps) if step.get("name") == name)
def test_workflow_triggers_only_main_and_v_tags():
def test_workflow_triggers_only_v_tag_push_candidates():
workflow = _workflow()
assert set(workflow["on"]) == {"push"}
assert workflow["on"]["push"] == {"branches": ["main"], "tags": ["v*"]}
assert workflow["on"]["push"] == {"tags": ["v*"]}
def test_workflow_rejects_non_strict_version_events_before_checkout_and_build():
steps = _workflow()["jobs"]["verify"]["steps"]
guard = steps[0]
assert guard["name"] == "Reject non-version publication events"
assert guard["env"] == {
"PUBLICATION_EVENT": "${{ github.event_name }}",
"PUBLICATION_REF": "${{ github.ref }}",
}
assert '"${PUBLICATION_EVENT}" != "push"' in guard["run"]
strict_ref = (
r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"
)
assert strict_ref in guard["run"]
assert _step_index(steps, guard["name"]) < _step_index(steps, "Check out exact source commit")
assert _step_index(steps, guard["name"]) < _step_index(steps, "Build source image once")
def test_publish_is_gated_and_has_the_only_package_write_permission():
@@ -123,6 +140,7 @@ def test_publish_job_has_only_download_then_verified_publish_helper():
--image-id tested-image/trex-core-image.id \\
--local-image "trex-core-ci:${GITHUB_SHA}" \\
--commit "${GITHUB_SHA}" \\
--event-name "${GITHUB_EVENT_NAME}" \\
--ref "${GITHUB_REF}"'''
assert steps[1]["run"].strip() == expected_run
@@ -148,7 +166,10 @@ def test_workflow_uses_full_sha_and_contains_no_latest_tag():
def test_docker_context_contract():
dockerfile = (REPO / "Dockerfile").read_text()
copy_lines = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("COPY ")]
assert copy_lines == ["COPY pyproject.toml ./", "COPY trex_core/ ./trex_core/"]
assert copy_lines == ["COPY pyproject.toml uv.lock ./", "COPY trex_core/ ./trex_core/"]
assert 'python -m pip install --no-cache-dir "uv==0.11.14"' in dockerfile
assert "uv sync --locked --no-dev --no-editable" in dockerfile
assert "pip install --no-cache-dir ." not in dockerfile
assert "org.opencontainers.image.revision" in dockerfile
assert "python:3.12" in dockerfile
@@ -158,6 +179,9 @@ def test_docker_context_contract():
def test_operator_documentation_preserves_rollback_boundaries():
operations = (REPO / "docs" / "operations.md").read_text()
assert "strict `vX.Y.Z` Git tag" in operations
assert "사용자 승인 외부 쓰기" in operations
assert "Git tag version은 `pyproject.toml` package version과 독립" in operations
assert "read-only volume" in operations
assert "runtime environment" in operations
assert "sha256:<64-hex>" in operations
+84 -13
View File
@@ -64,18 +64,50 @@ def _artifact(tmp_path: Path) -> tuple[Path, Path, Path]:
def test_release_tags_are_deterministic_and_never_latest():
main = image_release.release_tags(COMMIT, "refs/heads/main")
release = image_release.release_tags(COMMIT, "refs/tags/v1.2.3")
release = image_release.release_tags(COMMIT, "push", "refs/tags/v1.2.3")
assert main == [f"{image_release.IMAGE}:{COMMIT[:12]}"]
assert release == [*main, f"{image_release.IMAGE}:v1.2.3"]
assert all(not tag.endswith(":latest") for tag in main + release)
assert release == [
f"{image_release.IMAGE}:{COMMIT[:12]}",
f"{image_release.IMAGE}:v1.2.3",
]
assert all(not tag.endswith(":latest") for tag in release)
@pytest.mark.parametrize("ref", ["refs/tags/v0.0.0", "refs/tags/v10.20.30"])
def test_strict_version_tags_accept_zero_and_nonzero_decimal_components(ref):
assert image_release.validate_publication_trigger("push", ref) == ref.removeprefix(
"refs/tags/"
)
@pytest.mark.parametrize(
("event_name", "ref"),
[
("workflow_dispatch", "refs/tags/v1.2.3"),
("pull_request", "refs/tags/v1.2.3"),
("push", "refs/heads/main"),
("push", "refs/heads/release"),
("push", "refs/tags/1.2.3"),
("push", "refs/tags/v1.2"),
("push", "refs/tags/v1.2.3.4"),
("push", "refs/tags/v01.2.3"),
("push", "refs/tags/v1.02.3"),
("push", "refs/tags/v1.2.03"),
("push", "refs/tags/v1.2.3-rc.1"),
("push", "refs/tags/v1.2.3+build.1"),
("push", "refs/tags/v-1.2.3"),
("push", "refs/tags/v1.a.3"),
],
)
def test_non_push_branch_and_malformed_version_triggers_are_rejected(event_name, ref):
with pytest.raises(ValueError):
image_release.validate_publication_trigger(event_name, ref)
@pytest.mark.parametrize("commit", ["A" * 40, "a" * 39, "g" * 40, ""])
def test_invalid_source_commit_is_rejected(commit):
with pytest.raises(ValueError):
image_release.release_tags(commit, "refs/heads/main")
image_release.release_tags(commit, "push", "refs/tags/v1.2.3")
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
@@ -156,7 +188,38 @@ def test_mutated_archive_stops_before_load_or_registry(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
)
assert runner.commands == []
@pytest.mark.parametrize(
("event_name", "ref"),
[
("workflow_dispatch", "refs/tags/v1.2.3"),
("push", "refs/heads/main"),
("push", "refs/tags/v01.2.3"),
("push", "refs/tags/v1.2.3-rc.1"),
],
)
def test_invalid_publication_trigger_stops_before_artifact_load_or_registry(
tmp_path, event_name, ref
):
runner = RecordingRunner()
with pytest.raises(ValueError, match="publication"):
image_release.publish_image(
archive=tmp_path / "missing.tar",
checksum=tmp_path / "missing.sha256",
image_id=tmp_path / "missing.id",
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name=event_name,
ref=ref,
actor="actor",
token="fake-token",
runner=runner,
@@ -175,7 +238,8 @@ def test_load_failure_stops_before_login_and_push(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -194,7 +258,8 @@ def test_revision_mismatch_stops_before_login_and_push(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -213,7 +278,8 @@ def test_expected_image_id_mismatch_stops_before_credentials_tags_or_push(tmp_pa
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -235,7 +301,8 @@ def test_unrelated_load_output_cannot_use_stale_expected_local_tag(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -262,7 +329,8 @@ def test_invalid_tested_image_id_stops_before_load(tmp_path, invalid_id):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -292,6 +360,7 @@ def test_valid_publish_records_digest_only_after_push(tmp_path):
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v2.0.0",
actor="actor",
token="fake-token",
@@ -323,7 +392,8 @@ def test_invalid_post_push_digest_emits_no_success_evidence(tmp_path, bad_digest
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
ref="refs/heads/main",
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
@@ -356,6 +426,7 @@ def test_release_second_push_failure_preserves_nonzero_without_success_evidence(
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v2.0.0",
actor="actor",
token="fake-token",