diff --git a/.github/workflows/publish-image.yml b/.github/workflows/publish-image.yml index dbb1839..f9e1a03 100644 --- a/.github/workflows/publish-image.yml +++ b/.github/workflows/publish-image.yml @@ -2,8 +2,6 @@ name: Publish verified container image on: push: - branches: - - main tags: - "v*" @@ -16,12 +14,26 @@ jobs: permissions: contents: read steps: + - name: Reject non-version publication events + env: + PUBLICATION_EVENT: ${{ github.event_name }} + PUBLICATION_REF: ${{ github.ref }} + run: | + if [[ "${PUBLICATION_EVENT}" != "push" \ + || ! "${PUBLICATION_REF}" =~ ^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "publication requires a strict vX.Y.Z tag push" >&2 + exit 1 + fi + - name: Check out exact source commit uses: actions/checkout@v4 with: 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: @@ -108,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 @@ -127,4 +140,5 @@ jobs: --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}" diff --git a/Dockerfile b/Dockerfile index 691ceb1..6260523 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,12 +13,15 @@ RUN test "${#SOURCE_COMMIT}" -eq 40 \ esac \ || { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; } -COPY pyproject.toml ./ +COPY pyproject.toml uv.lock ./ COPY trex_core/ ./trex_core/ -RUN python -m pip install --no-cache-dir . \ +RUN python -m pip install --no-cache-dir "uv==0.11.14" \ + && uv sync --locked --no-dev --no-editable \ && useradd --create-home --uid 10001 trex +ENV PATH="/opt/trex-core/.venv/bin:${PATH}" + USER 10001:10001 ENTRYPOINT ["trex-core-daemon"] diff --git a/docs/operations.md b/docs/operations.md index 3860f8c..1a17c63 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -42,6 +42,8 @@ pkill -TERM -f trex-core-daemon ### 컨테이너 설정, 배포 및 rollback +- GHCR publication은 requester가 별도로 승인하여 생성하고 push한 strict `vX.Y.Z` Git tag에서만 시작한다. 숫자 구성요소는 `0` 또는 leading zero 없는 decimal integer이며 prerelease/build suffix는 허용하지 않는다. 일반 `main`/branch push, PR, non-push event와 malformed tag는 image를 게시하지 않는다. tag 생성과 push 자체는 이 저장소의 구현·검증과 분리된 사용자 승인 외부 쓰기다. +- Git tag version은 `pyproject.toml` package version과 독립이다. 두 값의 일치를 publication 조건으로 해석하거나 package version 변경만으로 publication이 시작된다고 해석하지 않는다. - 이미지는 per-instance TOML, `.env`, 자격증명 또는 연결 정보를 포함하지 않는다. 인스턴스 TOML은 read-only volume으로 mount하고 `trex-core-daemon --config `로 지정한다. secret은 배포 플랫폼의 secret 기능 또는 runtime environment로만 주입하고 image layer나 command line에 기록하지 않는다. - 게시된 `ghcr.io/fourblocks2020/trex-core:` tag는 source commit을 찾는 stable selector일 뿐이다. 배포 전 registry가 반환한 `ghcr.io/fourblocks2020/trex-core@sha256:<64-hex>` reference를 기록하고 workload는 이 digest로 pin한다. - rollback은 이전에 검증하고 기록한 digest reference로 workload image를 되돌린 뒤 정상적인 배포 절차로 재시작한다. tag를 다시 해석하거나 source에서 다시 build하지 않는다. diff --git a/scripts/image_release.py b/scripts/image_release.py index 8ecfa9e..a6816e6 100644 --- a/scripts/image_release.py +++ b/scripts/image_release.py @@ -20,6 +20,7 @@ CANARY = b"TREX_SECRET_CANARY_DO_NOT_SHIP" COMMIT_RE = re.compile(r"^[0-9a-f]{40}$") DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)") IMAGE_ID_RE = re.compile(r"^sha256:[0-9a-f]{64}$") +VERSION_TAG_RE = re.compile(r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$") DEFAULT_MIGRATION_DIR = ( Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql" ) @@ -100,19 +101,26 @@ def validate_commit(commit: str) -> str: return commit -def release_tags(commit: str, ref: str) -> list[str]: +def validate_publication_trigger(event_name: str, ref: str) -> str: + if event_name != "push": + raise ValueError("publication event must be a push") + if not VERSION_TAG_RE.fullmatch(ref): + raise ValueError("publication ref must be a strict vX.Y.Z tag") + 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) - tags = [f"{IMAGE}:{commit[:12]}"] - if ref == "refs/heads/main": - return tags - prefix = "refs/tags/" - if ref.startswith(prefix) and ref.removeprefix(prefix).startswith("v"): - release = ref.removeprefix(prefix) - if not re.fullmatch(r"v[A-Za-z0-9._-]*", release): - raise ValueError("release ref contains unsupported tag characters") - tags.append(f"{IMAGE}:{release}") - return tags - raise ValueError("publication ref must be main or a v* tag") + release = validate_publication_trigger(event_name, ref) + return [f"{IMAGE}:{commit[:12]}", f"{IMAGE}:{release}"] def sha256_file(path: Path) -> str: @@ -359,6 +367,7 @@ def publish_image( image_id: Path, local_image: str, commit: str, + event_name: str, ref: str, actor: str, token: str, @@ -366,7 +375,8 @@ def publish_image( output_path: Path | None = None, summary_path: Path | None = None, ) -> str: - tags = release_tags(commit, ref) + 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)]) @@ -406,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 @@ -425,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) @@ -450,6 +467,7 @@ def _parser() -> argparse.ArgumentParser: publish.add_argument("--image-id", type=Path, required=True) publish.add_argument("--local-image", required=True) publish.add_argument("--commit", required=True) + publish.add_argument("--event-name", required=True) publish.add_argument("--ref", required=True) return parser @@ -459,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": @@ -476,6 +496,7 @@ def main(argv: Sequence[str] | None = None) -> int: image_id=args.image_id, local_image=args.local_image, commit=args.commit, + event_name=args.event_name, ref=args.ref, actor=os.environ.get("GITHUB_ACTOR", ""), token=os.environ.get("REGISTRY_TOKEN", ""), diff --git a/tests/release/test_image_contracts.py b/tests/release/test_image_contracts.py index 6030b36..f8ee506 100644 --- a/tests/release/test_image_contracts.py +++ b/tests/release/test_image_contracts.py @@ -17,10 +17,43 @@ 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_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(): @@ -34,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] @@ -123,6 +166,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 +192,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 +205,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 diff --git a/tests/release/test_image_release.py b/tests/release/test_image_release.py index 31791fd..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: @@ -64,18 +66,65 @@ 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_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(): @@ -156,7 +205,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 +255,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 +275,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 +295,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 +318,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 +346,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, @@ -280,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" @@ -292,6 +377,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", @@ -304,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]) @@ -323,7 +419,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 +453,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",