167 lines
6.2 KiB
Python
167 lines
6.2 KiB
Python
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
|