from __future__ import annotations from pathlib import Path import yaml REPO = Path(__file__).resolve().parents[2] WORKFLOW_PATH = REPO / ".github" / "workflows" / "publish-image.yml" def _workflow(): return yaml.load(WORKFLOW_PATH.read_text(), Loader=yaml.BaseLoader) 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(): workflow = _workflow() assert set(workflow["on"]) == {"push"} assert workflow["on"]["push"] == {"branches": ["main"], "tags": ["v*"]} def test_publish_is_gated_and_has_the_only_package_write_permission(): workflow = _workflow() verify = workflow["jobs"]["verify"] publish = workflow["jobs"]["publish"] assert publish["needs"] == "verify" assert publish["permissions"]["packages"] == "write" assert "packages" not in workflow["permissions"] assert "packages" not in verify["permissions"] def test_verify_canary_build_smoke_export_scan_checksum_upload_contract(): steps = _workflow()["jobs"]["verify"]["steps"] names = [step["name"] for step in steps] assert names.count("Create excluded synthetic canary") == 1 assert names.count("Build source image once") == 1 assert names.count("Scan all canary surfaces") == 1 canary = steps[_step_index(steps, "Create excluded synthetic canary")]["run"] assert 'Path(".env.ci-canary")' in canary assert "TREX_SECRET_CANARY_DO_NOT_SHIP" in canary scan = steps[_step_index(steps, "Scan all canary surfaces")]["run"] assert "image_release.py scan" in scan status_contract = '''>canary-scan.stdout 2>canary-scan.stderr scan_status=$? set -e python scripts/image_release.py assert-clean-output \\ canary-scan.stdout canary-scan.stderr exit "${scan_status}"''' assert status_contract in scan assert scan.count("scan_status=$?") == 1 assert scan.rstrip().endswith('exit "${scan_status}"') assert _step_index(steps, "Run repository lint") < _step_index(steps, "Build source image once") assert _step_index(steps, "Run repository tests") < _step_index(steps, "Build source image once") ordered = [ "Create excluded synthetic canary", "Build source image once", "Smoke built image", "Export tested image", "Scan all canary surfaces", "Record tested archive checksum", "Upload immutable tested image artifact", ] assert [_step_index(steps, name) for name in ordered] == sorted( _step_index(steps, name) for name in ordered ) def test_repository_checks_use_the_locked_uv_environment(): steps = _workflow()["jobs"]["verify"]["steps"] install = steps[_step_index(steps, "Install repository checks")]["run"] lint = steps[_step_index(steps, "Run repository lint")]["run"] tests = steps[_step_index(steps, "Run repository tests")]["run"] assert 'python -m pip install "uv==0.11.14"' in install assert "uv sync --extra dev --locked" in install assert lint == "uv run ruff check ." assert tests == "uv run pytest" assert 'pip install ".[dev]"' not in install def test_tested_image_id_is_recorded_after_smoke_and_transferred_to_publish(): workflow = _workflow() verify_steps = workflow["jobs"]["verify"]["steps"] publish_steps = workflow["jobs"]["publish"]["steps"] smoke_index = _step_index(verify_steps, "Smoke built image") identity_index = _step_index(verify_steps, "Record tested image ID") export_index = _step_index(verify_steps, "Export tested image") assert smoke_index < identity_index < export_index identity = verify_steps[identity_index]["run"] assert "image_release.py record-image-id" in identity assert "--output trex-core-image.id" in identity upload = verify_steps[_step_index(verify_steps, "Upload immutable tested image artifact")]["with"] assert "trex-core-image.id" in upload["path"].splitlines() publish = publish_steps[_step_index(publish_steps, "Verify, load, and publish without rebuilding")][ "run" ] assert "--image-id tested-image/trex-core-image.id" in publish def test_publish_job_has_only_download_then_verified_publish_helper(): steps = _workflow()["jobs"]["publish"]["steps"] assert len(steps) == 2 assert "run" not in steps[0] assert steps[0]["uses"].startswith("actions/download-artifact@") run_steps = [step for step in steps if "run" in step] assert run_steps == [steps[1]] expected_run = '''python tested-image/image_release.py publish \\ --archive tested-image/trex-core-image.tar \\ --checksum tested-image/trex-core-image.tar.sha256 \\ --image-id tested-image/trex-core-image.id \\ --local-image "trex-core-ci:${GITHUB_SHA}" \\ --commit "${GITHUB_SHA}" \\ --ref "${GITHUB_REF}"''' assert steps[1]["run"].strip() == expected_run rendered = "\n".join(str(step) for step in steps).lower() for forbidden in ( "actions/checkout", "docker login", "docker push", "docker build", "buildx", ): assert forbidden not in rendered def test_workflow_uses_full_sha_and_contains_no_latest_tag(): text = WORKFLOW_PATH.read_text() assert "${GITHUB_SHA}" in text assert "github.sha" in text assert "--provenance=false" in text assert ":latest" not in text.lower() 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 "org.opencontainers.image.revision" in dockerfile assert "python:3.12" in dockerfile ignored = set((REPO / ".dockerignore").read_text().splitlines()) assert {".git", ".env", ".env.*", "config/", ".proofline/"} <= ignored def test_operator_documentation_preserves_rollback_boundaries(): operations = (REPO / "docs" / "operations.md").read_text() assert "read-only volume" in operations assert "runtime environment" in operations assert "sha256:<64-hex>" in operations assert "이전" in operations and "digest" in operations assert "`trex-core-reset`" in operations assert "자동 rollback 절차에 절대로 포함하지 않는다" in operations