retire GHCR image publication

This commit is contained in:
Hermes Agent
2026-08-28 21:52:51 +09:00
parent dd2ae33ee1
commit ddf7d6710b
11 changed files with 8 additions and 1679 deletions
-144
View File
@@ -1,144 +0,0 @@
name: Publish verified container image
on:
push:
tags:
- "v*"
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
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:
python-version: "3.12"
cache: pip
- name: Install repository checks
run: |
python -m pip install "uv==0.11.14"
uv sync --extra dev --locked
- name: Run repository lint
run: uv run ruff check .
- name: Run repository tests
run: uv run pytest
- name: Create excluded synthetic canary
run: |
python - <<'PY'
from pathlib import Path
Path(".env.ci-canary").write_text("TREX_SECRET_CANARY_DO_NOT_SHIP\n", encoding="ascii")
PY
- name: Build source image once
run: |
docker build \
--provenance=false \
--build-arg "SOURCE_COMMIT=${GITHUB_SHA}" \
--tag "trex-core-ci:${GITHUB_SHA}" \
.
- name: Smoke built image
run: python scripts/image_release.py smoke --image "trex-core-ci:${GITHUB_SHA}"
- name: Record tested image ID
run: |
python scripts/image_release.py record-image-id \
--image "trex-core-ci:${GITHUB_SHA}" \
--output trex-core-image.id
- name: Export tested image
run: docker save --output trex-core-image.tar "trex-core-ci:${GITHUB_SHA}"
- name: Scan all canary surfaces
run: |
set +e
python scripts/image_release.py scan \
--image "trex-core-ci:${GITHUB_SHA}" \
--archive trex-core-image.tar \
>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}"
- name: Record tested archive checksum
run: |
python scripts/image_release.py checksum \
--archive trex-core-image.tar \
--output trex-core-image.tar.sha256
- name: Upload immutable tested image artifact
uses: actions/upload-artifact@v4
with:
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
if-no-files-found: error
overwrite: false
retention-days: 7
path: |
trex-core-image.tar
trex-core-image.tar.sha256
trex-core-image.id
scripts/image_release.py
publish:
needs: verify
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
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 }}
steps:
- name: Download tested image artifact
uses: actions/download-artifact@v4
with:
name: tested-image-${{ github.run_id }}-${{ github.run_attempt }}
path: tested-image
- name: Verify, load, and publish without rebuilding
id: publish
env:
GITHUB_ACTOR: ${{ github.actor }}
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python tested-image/scripts/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}" \
--event-name "${GITHUB_EVENT_NAME}" \
--ref "${GITHUB_REF}"
@@ -1,72 +0,0 @@
name: Verify published trex-core image
on:
workflow_dispatch:
inputs:
version:
description: "Strict release tag (for example v0.2.0)"
required: true
type: string
commit:
description: "Exact 40-character lowercase source commit"
required: true
type: string
permissions:
contents: read
packages: read
jobs:
verify:
runs-on: ubuntu-24.04
steps:
- name: Validate immutable selectors and image contract
env:
REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ACTOR: ${{ github.actor }}
VERSION: ${{ inputs.version }}
COMMIT: ${{ inputs.commit }}
IMAGE: ghcr.io/fourblocks2020/trex-core
run: |
set -euo pipefail
[[ "$VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
[[ "$COMMIT" =~ ^[0-9a-f]{40}$ ]]
short_sha="${COMMIT:0:12}"
printf '%s' "$REGISTRY_TOKEN" | docker login ghcr.io --username "$ACTOR" --password-stdin >/dev/null
version_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$VERSION")"
source_raw="$(docker buildx imagetools inspect --raw "$IMAGE:$short_sha")"
version_digest="sha256:$(printf '%s' "$version_raw" | sha256sum | cut -d' ' -f1)"
source_digest="sha256:$(printf '%s' "$source_raw" | sha256sum | cut -d' ' -f1)"
test "$version_digest" = "$source_digest"
docker pull "$IMAGE@$version_digest" >/dev/null
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Architecture}}')" = amd64
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{.Config.User}}')" = 10001:10001
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$COMMIT"
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{index .Config.Labels "org.opencontainers.image.source"}}')" = https://github.com/fourblocks2020/trex-core
test "$(docker image inspect "$IMAGE@$version_digest" --format '{{json .Config.Entrypoint}}')" = '["trex-core-daemon"]'
test "$(docker run --rm --entrypoint python "$IMAGE@$version_digest" -c 'from trex_core.adapters.persistence.migrate import migration_files; p=migration_files(); print(f"count={len(p)} last={p[-1].name}")')" = 'count=21 last=022_persist_order_timeout_anchor.sql'
if docker buildx imagetools inspect "$IMAGE:latest" >/dev/null 2>&1; then
echo "mutable latest selector exists" >&2
exit 1
fi
echo "digest=$version_digest"
echo "version_selector=$IMAGE:$VERSION"
echo "source_selector=$IMAGE:$short_sha"
echo "immutable=$IMAGE@$version_digest"
{
echo "## Verified published trex-core image"
echo
echo "- digest: \`$version_digest\`"
echo "- version selector: \`$IMAGE:$VERSION\`"
echo "- source selector: \`$IMAGE:$short_sha\`"
echo "- source commit: \`$COMMIT\`"
echo "- architecture: \`linux/amd64\`"
echo "- runtime user: \`10001:10001\`"
echo "- entrypoint: \`trex-core-daemon\`"
echo "- migration inventory: \`21\`, last \`022_persist_order_timeout_anchor.sql\`"
echo "- latest: absent"
} >> "$GITHUB_STEP_SUMMARY"
+2 -2
View File
@@ -1,6 +1,6 @@
---
id: "ac-0004"
status: active
status: retired
---
# strict version-tag 검증 성공 이미지에 한정된 GHCR 게시
@@ -13,4 +13,4 @@ GitHub Actions publication workflow는 strict `vX.Y.Z` tag push만 후보로 받
- 성공 조건: synthetic strict tag-push fixture에서 exact checkout 재검증, full repository verification, single build, smoke, tested-artifact 생성·전달·checksum·load 검증을 모두 통과해야 publish job의 login/push가 실행 가능하고, 두 push가 같은 유효 digest를 반환한 뒤에만 성공 evidence가 생성된다.
- 실패·경계 조건: non-push, branch, malformed/leading-zero/prerelease/build tag, invalid source commit, checkout `HEAD` mismatch, repository 검증·build·smoke·artifact·checksum·load 실패는 registry credential/write 전에 nonzero로 종료된다. post-push digest 오류·불일치는 nonzero와 성공 evidence 부재로 이어진다.
- 검사 방법: `tests/release/test_image_contracts.py`, `tests/release/test_image_release.py`의 workflow parser·recording-runner tests로 trigger, guard 순서, exact checkout, `publish.needs`, publish-job-only `packages: write`, registry write 전후 실패 경계를 검증하고 전체 Ruff·pytest를 실행한다. 실제 tag push·GHCR write는 별도 승인된 publication evidence로 확인한다.
- 검사 방법: `tests/release/test_image_contracts.py`, `tests/release/test_image_release.py`의 workflow parser·recording-runner tests로 trigger, guard 순서, exact checkout, `publish.needs`, publish-job-only `packages: write`, registry write 전후 실패 경계를 검증하고 전체 Ruff·pytest를 실행한다. 실제 tag push·GHCR write는 별도 승인된 publication evidence로 확인한다.
+2 -2
View File
@@ -1,6 +1,6 @@
---
id: "ac-0005"
status: active
status: retired
---
# 검증 이미지와 게시 이미지의 동일성
@@ -13,4 +13,4 @@ verify job은 한 번 빌드하여 built-container smoke를 통과한 `trex-core
- 성공 조건: smoke가 성공한 local image를 export한 archive의 SHA-256과 publish job이 download한 archive의 SHA-256이 일치하고, checksum 확인 뒤 load된 image가 GHCR tag의 유일한 원본이 된다.
- 실패·경계 조건: archive 또는 checksum 누락, archive byte 변조, checksum 불일치, load 실패, publish job의 checkout·build 또는 별도 source-based image 생성은 registry login/push 전 nonzero로 종료된다.
- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다.
- 검사 방법: workflow 구조 테스트로 smoke→save→checksum→upload 및 download→checksum compare→load→tag→push 순서를 검사하고, recording-runner 단위 테스트에서 원본·변조 archive fixture를 사용해 mismatch 시 load/login/push가 호출되지 않음을 검증한다.
+2 -2
View File
@@ -1,6 +1,6 @@
---
id: "ac-0006"
status: active
status: retired
---
# requester version tag·source tag와 immutable digest 추적성
@@ -13,4 +13,4 @@ status: active
- 성공 조건: strict `vX.Y.Z` tag event는 같은 loaded image에 `:<source-commit-first-12>`와 `:vX.Y.Z`를 만들고 두 push가 동일한 digest를 반환한다. OCI revision과 output·summary의 immutable image reference, digest, source commit, version tag가 event와 push 결과에 정확히 일치하며 package version 값은 tag 생성·검증에 사용되지 않는다. 운영 문서는 version authority·package version 독립성·별도 승인 tag push 경계를 모두 설명하고 기존 published identity를 보존한다.
- 실패·경계 조건: source commit 또는 version tag 형식 오류, SHA tag 파생 불일치, version/package version 일치 강제, tag별 별도 build, `latest` 생성, OCI revision 불일치, digest 누락·형식 오류·tag 간 불일치가 있으면 성공 evidence를 생성할 수 없다. 기존 published tag·digest의 삭제·이동·재지정 또는 operator documentation의 version authority·별도 승인 경계 누락도 FAIL이다. push 이후 오류는 job nonzero로 유지한다.
- 검사 방법: `scripts/image_release.py`의 trigger/tag/publish helper 단위 테스트와 workflow contract test로 deterministic tag, same-loaded-image, no rebuild, same digest, no-`latest`와 output·summary exactness를 검증하고 Docker image label을 inspect한다. `docs/operations.md` contract test로 Git tag authority, package version 독립성과 requester-approved tag creation/push 경계를 검사하고, workflow·helper가 기존 published registry identity를 삭제·이동·재지정하는 command를 포함하지 않는지 정적 검사한다. 실제 registry의 두 tag·digest와 기존 identity 보존은 별도 승인된 publication run에서 immutable evidence로 확인한다.
- 검사 방법: `scripts/image_release.py`의 trigger/tag/publish helper 단위 테스트와 workflow contract test로 deterministic tag, same-loaded-image, no rebuild, same digest, no-`latest`와 output·summary exactness를 검증하고 Docker image label을 inspect한다. `docs/operations.md` contract test로 Git tag authority, package version 독립성과 requester-approved tag creation/push 경계를 검사하고, workflow·helper가 기존 published registry identity를 삭제·이동·재지정하는 command를 포함하지 않는지 정적 검사한다. 실제 registry의 두 tag·digest와 기존 identity 보존은 별도 승인된 publication run에서 immutable evidence로 확인한다.
+2 -2
View File
@@ -1,6 +1,6 @@
---
id: "ac-0007"
status: active
status: retired
---
# built-container package 및 entrypoint smoke
@@ -13,4 +13,4 @@ GHCR 게시 후보는 source tree가 아니라 실제로 빌드된 image 안에
- 성공 조건: network가 차단되고 운영 config·credential이 없는 최소권한 container에서 기존 네 probe와 installed migration inventory probe가 각각 정해진 timeout 안에 exit code `0`으로 끝나며 container가 정리된다. 현재 회귀 기준선에는 canonical SQL 파일 20개가 있으나, 영구 성공 조건은 고정 수량이 아니라 해당 source commit의 non-empty exact inventory parity이다.
- 실패·경계 조건: package import 실패, entrypoint 누락, installed migration inventory의 empty·누락·추가·filename mismatch, nonzero exit, timeout, 외부 연결 시도 또는 cleanup 실패는 smoke nonzero로 이어지고 image publish를 차단한다. `trex-core-reset` probe에는 `--yes` 또는 config argument를 전달하지 않는다.
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
- 검사 방법: built wheel의 SQL filename set을 source-controlled canonical set과 대조하고, built image를 대상으로 하는 smoke harness와 recording-runner 단위 테스트로 정확한 기존 네 command와 side-effect-free installed inventory probe, per-command timeout, network 차단, no-config/no-secret 환경, failure propagation 및 cleanup을 검증하며 CI에서 실제 local image smoke를 실행한다. Inventory probe는 migration을 적용하거나 DB 또는 다른 service에 연결하지 않는다.
-27
View File
@@ -1,27 +0,0 @@
FROM python:3.12-slim
ARG SOURCE_COMMIT
LABEL org.opencontainers.image.source="https://github.com/fourblocks2020/trex-core" \
org.opencontainers.image.revision="${SOURCE_COMMIT}"
WORKDIR /opt/trex-core
RUN test "${#SOURCE_COMMIT}" -eq 40 \
&& case "${SOURCE_COMMIT}" in \
*[!0-9a-f]*) false ;; \
*) true ;; \
esac \
|| { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; }
COPY pyproject.toml uv.lock ./
COPY trex_core/ ./trex_core/
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"]
-10
View File
@@ -40,16 +40,6 @@ pkill -TERM -f trex-core-daemon
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
- 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN.
### 컨테이너 설정, 배포 및 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 <mounted-path>`로 지정한다. secret은 배포 플랫폼의 secret 기능 또는 runtime environment로만 주입하고 image layer나 command line에 기록하지 않는다.
- 게시된 `ghcr.io/fourblocks2020/trex-core:<source-commit-first-12>` 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하지 않는다.
- `trex-core-reset`은 파괴적인 DB 작업이며 image rollback이나 자동 rollback 절차에 절대로 포함하지 않는다. schema/data 변경의 복구는 image rollback과 분리된 승인 절차로 수행한다.
- v0.1.1부터 image smoke는 DB에 연결하거나 migration을 적용하지 않고, installed package의 SQL migration filename inventory가 해당 source commit의 non-empty canonical inventory와 정확히 일치하는지 검증한다.
---
## 2. 운영자 CLI
-515
View File
@@ -1,515 +0,0 @@
#!/usr/bin/env python3
"""Deterministic verification and publication helpers for the release image."""
from __future__ import annotations
import argparse
import hashlib
import os
import re
import subprocess
import sys
import tarfile
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol, Sequence
IMAGE = "ghcr.io/fourblocks2020/trex-core"
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"
)
MAX_ARCHIVE_DEPTH = 8
MAX_ARCHIVE_MEMBERS = 200_000
MAX_ARCHIVE_EXTRACTED_BYTES = 2 * 1024 * 1024 * 1024
MAX_ARCHIVE_MEMBER_BYTES = 512 * 1024 * 1024
MAX_ARCHIVE_INPUT_BYTES = 1024 * 1024 * 1024
@dataclass(frozen=True)
class Result:
stdout: str = ""
stderr: str = ""
@dataclass
class _ScanBudget:
max_depth: int = MAX_ARCHIVE_DEPTH
max_members: int = MAX_ARCHIVE_MEMBERS
max_extracted_bytes: int = MAX_ARCHIVE_EXTRACTED_BYTES
max_member_bytes: int = MAX_ARCHIVE_MEMBER_BYTES
max_input_bytes: int = MAX_ARCHIVE_INPUT_BYTES
members: int = 0
extracted_bytes: int = 0
def check_depth(self, depth: int) -> None:
if depth > self.max_depth:
_scan_limit_exceeded()
def add_member(self, size: int) -> None:
self.members += 1
if self.members > self.max_members:
_scan_limit_exceeded()
if size < 0 or size > self.max_member_bytes:
_scan_limit_exceeded()
self.extracted_bytes += size
if self.extracted_bytes > self.max_extracted_bytes:
_scan_limit_exceeded()
def _scan_limit_exceeded() -> None:
raise ValueError("archive scan resource limit exceeded")
class Runner(Protocol):
def run(
self,
command: Sequence[str],
*,
input_text: str | None = None,
timeout: int | None = None,
) -> Result: ...
class SubprocessRunner:
def run(
self,
command: Sequence[str],
*,
input_text: str | None = None,
timeout: int | None = None,
) -> Result:
completed = subprocess.run(
command,
input=input_text,
text=True,
capture_output=True,
check=True,
timeout=timeout,
)
return Result(completed.stdout, completed.stderr)
def validate_commit(commit: str) -> str:
if not COMMIT_RE.fullmatch(commit):
raise ValueError("source commit must be 40 lowercase hexadecimal characters")
return commit
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)
release = validate_publication_trigger(event_name, ref)
return [f"{IMAGE}:{commit[:12]}", f"{IMAGE}:{release}"]
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as source:
for chunk in iter(lambda: source.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def write_checksum(archive: Path, checksum: Path) -> None:
checksum.write_text(f"{sha256_file(archive)} {archive.name}\n", encoding="ascii")
def verify_checksum(archive: Path, checksum: Path) -> None:
if not archive.is_file() or not checksum.is_file():
raise ValueError("image archive or checksum evidence is missing")
fields = checksum.read_text(encoding="ascii").strip().split()
if len(fields) != 2 or fields[1] != archive.name or not re.fullmatch(r"[0-9a-f]{64}", fields[0]):
raise ValueError("checksum evidence has an invalid format")
if sha256_file(archive) != fields[0]:
raise ValueError("image archive checksum mismatch")
def validate_image_id(image_id: str) -> str:
if not IMAGE_ID_RE.fullmatch(image_id):
raise ValueError("image ID must be sha256 followed by 64 lowercase hexadecimal characters")
return image_id
def inspect_image_id(image: str, runner: Runner) -> str:
result = runner.run(["docker", "image", "inspect", "--format", "{{.Id}}", image])
return validate_image_id(result.stdout.strip())
def write_image_id(image: str, output: Path, runner: Runner) -> str:
image_id = inspect_image_id(image, runner)
output.write_text(f"{image_id}\n", encoding="ascii")
return image_id
def read_image_id(path: Path) -> str:
text = path.read_text(encoding="ascii")
if not re.fullmatch(r"sha256:[0-9a-f]{64}\n?", text):
raise ValueError("tested image ID evidence has an invalid format")
return text.rstrip("\n")
def verify_image_id(image: str, expected_path: Path, runner: Runner) -> str:
expected = read_image_id(expected_path)
actual = inspect_image_id(image, runner)
if actual != expected:
raise ValueError("loaded image ID does not match tested image ID")
return actual
SMOKE_PROBES: tuple[tuple[str, ...], ...] = (
("python", "-c", "import trex_core"),
("trex-core-daemon", "--help"),
("trex-core", "--help"),
("trex-core-reset", "--help"),
)
def canonical_migration_names(migration_dir: Path = DEFAULT_MIGRATION_DIR) -> tuple[str, ...]:
names = tuple(sorted(path.name for path in migration_dir.glob("*.sql") if path.is_file()))
if not names:
raise ValueError("canonical migration inventory is empty")
return names
def migration_inventory_code(expected: tuple[str, ...]) -> str:
return (
"from trex_core.adapters.persistence.migrate import migration_files;"
f"expected={expected!r};"
"actual=tuple(path.name for path in migration_files());"
"assert expected and actual == expected, 'installed migration inventory mismatch'"
)
def _ensure_result_clean(result: Result) -> None:
if CANARY in (result.stdout + result.stderr).encode():
raise ValueError("canary marker found on a scanned surface")
def smoke_image(
image: str,
runner: Runner,
timeout: int = 30,
migration_dir: Path = DEFAULT_MIGRATION_DIR,
) -> None:
expected_migrations = canonical_migration_names(migration_dir)
probes = (
*SMOKE_PROBES,
("python", "-I", "-c", migration_inventory_code(expected_migrations)),
)
for index, probe in enumerate(probes):
name = f"trex-core-smoke-{os.getpid()}-{index}"
command = [
"docker",
"run",
"--name",
name,
"--network",
"none",
"--read-only",
"--cap-drop",
"ALL",
"--security-opt",
"no-new-privileges",
"--pids-limit",
"64",
"--tmpfs",
"/tmp:rw,noexec,nosuid,size=16m",
"--entrypoint",
probe[0],
image,
*probe[1:],
]
failure: BaseException | None = None
try:
_ensure_result_clean(runner.run(command, timeout=timeout))
except BaseException as error:
failure = error
try:
_ensure_result_clean(
runner.run(["docker", "rm", "--force", name], timeout=timeout)
)
except BaseException as cleanup_error:
if failure is None:
raise RuntimeError("smoke container cleanup failed") from cleanup_error
if failure is not None:
raise RuntimeError(f"smoke probe {index + 1} failed") from failure
def _scan_stream(path: Path) -> None:
with path.open("rb") as stream:
overlap = b""
while chunk := stream.read(1024 * 1024):
data = overlap + chunk
if CANARY in data:
raise ValueError("canary marker found on a scanned surface")
overlap = data[-(len(CANARY) - 1):]
def _scan_metadata(member: tarfile.TarInfo) -> None:
values = [member.name, member.linkname, member.uname, member.gname]
for key, value in member.pax_headers.items():
values.extend((key, value))
if any(CANARY in str(value).encode("utf-8", errors="replace") for value in values):
raise ValueError("canary marker found on a scanned surface")
def _scan_tar(path: Path, budget: _ScanBudget | None = None, depth: int = 0) -> None:
budget = budget or _ScanBudget()
if path.stat().st_size > (budget.max_input_bytes if depth == 0 else budget.max_member_bytes):
_scan_limit_exceeded()
_scan_stream(path)
try:
archive = tarfile.open(path, mode="r:*")
except (tarfile.TarError, OSError, EOFError):
return
with archive:
budget.check_depth(depth)
for member in archive:
budget.add_member(member.size if member.isfile() else 0)
_scan_metadata(member)
if not member.isfile():
continue
extracted = archive.extractfile(member)
if extracted is None:
continue
with tempfile.NamedTemporaryFile() as nested:
overlap = b""
copied = 0
while chunk := extracted.read(1024 * 1024):
copied += len(chunk)
if copied > member.size or copied > budget.max_member_bytes:
_scan_limit_exceeded()
data = overlap + chunk
if CANARY in data:
raise ValueError("canary marker found on a scanned surface")
nested.write(chunk)
overlap = data[-(len(CANARY) - 1):]
nested.flush()
_scan_tar(Path(nested.name), budget, depth + 1)
def scan_image(image: str, saved_archive: Path, runner: Runner, timeout: int = 60) -> None:
for command in (
["docker", "history", "--no-trunc", image],
["docker", "image", "inspect", image],
):
_ensure_result_clean(runner.run(command, timeout=timeout))
container = runner.run(["docker", "create", "--network", "none", image], timeout=timeout)
container_id = container.stdout.strip()
if not container_id:
raise RuntimeError("docker create returned no container id")
try:
_ensure_result_clean(container)
with tempfile.TemporaryDirectory() as directory:
filesystem = Path(directory) / "filesystem.tar"
_ensure_result_clean(
runner.run(
["docker", "export", "--output", str(filesystem), container_id],
timeout=timeout,
)
)
_scan_tar(filesystem)
finally:
try:
_ensure_result_clean(
runner.run(["docker", "rm", "--force", container_id], timeout=timeout)
)
except BaseException as error:
raise RuntimeError("canary scan container cleanup failed") from error
_scan_tar(saved_archive)
def assert_clean_output(paths: Sequence[Path]) -> None:
for path in paths:
_scan_stream(path)
def _push_digest(output: str) -> str:
digests = set(DIGEST_RE.findall(output))
if len(digests) != 1:
raise ValueError("registry push did not return exactly one valid digest")
return digests.pop()
def _append(path: Path | None, text: str) -> None:
if path is None:
return
with path.open("a", encoding="utf-8") as destination:
destination.write(text)
def publish_image(
*,
archive: Path,
checksum: Path,
image_id: Path,
local_image: str,
commit: str,
event_name: str,
ref: str,
actor: str,
token: str,
runner: Runner,
output_path: Path | None = None,
summary_path: Path | None = None,
) -> str:
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)])
load_lines = {line.strip() for line in (loaded.stdout + "\n" + loaded.stderr).splitlines()}
if f"Loaded image: {local_image}" not in load_lines:
raise ValueError("docker load did not report the expected local image")
actual_image_id = inspect_image_id(local_image, runner)
if actual_image_id != expected_image_id:
raise ValueError("loaded image ID does not match tested image ID")
revision = runner.run(
[
"docker",
"image",
"inspect",
"--format",
"{{ index .Config.Labels \"org.opencontainers.image.revision\" }}",
local_image,
]
).stdout.strip()
if revision != commit:
raise ValueError("loaded image revision does not match source commit")
if not actor or not token:
raise ValueError("registry credentials are missing")
for tag in tags:
runner.run(["docker", "tag", local_image, tag])
runner.run(
["docker", "login", "ghcr.io", "--username", actor, "--password-stdin"],
input_text=token,
)
digests = []
for tag in tags:
result = runner.run(["docker", "push", tag])
digests.append(_push_digest(result.stdout + "\n" + result.stderr))
if len(set(digests)) != 1:
raise ValueError("published tags returned different registry digests")
digest = digests[0]
reference = f"{IMAGE}@{digest}"
_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"- Version tag: `{version_tag}`\n",
)
return digest
def _parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser()
commands = parser.add_subparsers(dest="command", required=True)
smoke = commands.add_parser("smoke")
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)
checksum = commands.add_parser("checksum")
checksum.add_argument("--archive", type=Path, required=True)
checksum.add_argument("--output", type=Path, required=True)
record_id = commands.add_parser("record-image-id")
record_id.add_argument("--image", required=True)
record_id.add_argument("--output", type=Path, required=True)
verify_id = commands.add_parser("verify-image-id")
verify_id.add_argument("--image", required=True)
verify_id.add_argument("--expected", type=Path, required=True)
clean = commands.add_parser("assert-clean-output")
clean.add_argument("paths", nargs="+", type=Path)
publish = commands.add_parser("publish")
publish.add_argument("--archive", type=Path, required=True)
publish.add_argument("--checksum", type=Path, required=True)
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
def main(argv: Sequence[str] | None = None) -> int:
args = _parser().parse_args(argv)
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":
write_checksum(args.archive, args.output)
elif args.command == "record-image-id":
write_image_id(args.image, args.output, runner)
elif args.command == "verify-image-id":
verify_image_id(args.image, args.expected, runner)
elif args.command == "assert-clean-output":
assert_clean_output(args.paths)
elif args.command == "publish":
publish_image(
archive=args.archive,
checksum=args.checksum,
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", ""),
runner=runner,
output_path=Path(path) if (path := os.environ.get("GITHUB_OUTPUT")) else None,
summary_path=Path(path) if (path := os.environ.get("GITHUB_STEP_SUMMARY")) else None,
)
return 0
if __name__ == "__main__":
try:
raise SystemExit(main())
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
print(f"image release failed: {error}", file=sys.stderr)
raise SystemExit(1) from error
-216
View File
@@ -1,216 +0,0 @@
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_v_tag_push_candidates():
workflow = _workflow()
assert set(workflow["on"]) == {"push"}
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():
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_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]
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/scripts/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}" \\
--event-name "${GITHUB_EVENT_NAME}" \\
--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 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
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 "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
assert "이전" in operations and "digest" in operations
assert "`trex-core-reset`" in operations
assert "자동 rollback 절차에 절대로 포함하지 않는다" in operations
-687
View File
@@ -1,687 +0,0 @@
from __future__ import annotations
import gzip
import io
import subprocess
import tarfile
from pathlib import Path
import pytest
from scripts import image_release
COMMIT = "a" * 40
DIGEST = "sha256:" + "b" * 64
IMAGE_ID = "sha256:" + "c" * 64
REPO = Path(__file__).resolve().parents[2]
MIGRATION_DIR = REPO / "trex_core" / "adapters" / "persistence" / "sql"
class RecordingRunner:
def __init__(
self,
*,
fail_on: tuple[str, ...] | None = None,
digest: str = DIGEST,
revision: str = COMMIT,
image_id: str = IMAGE_ID,
loaded_image: str = f"trex-core-ci:{COMMIT}",
):
self.commands: list[tuple[list[str], str | None, int | None]] = []
self.fail_on = fail_on
self.digest = digest
self.revision = revision
self.image_id = image_id
self.loaded_image = loaded_image
def run(self, command, *, input_text=None, timeout=None):
command = list(command)
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:
return image_release.Result(self.image_id + "\n")
if command[:3] == ["docker", "image", "inspect"]:
return image_release.Result(self.revision + "\n")
if command[:2] == ["docker", "push"]:
return image_release.Result(f"digest: {self.digest} size: 1234\n")
if command[:2] == ["docker", "create"]:
return image_release.Result("container-id\n")
return image_release.Result()
def _artifact(tmp_path: Path) -> tuple[Path, Path, Path]:
archive = tmp_path / "trex-core-image.tar"
checksum = tmp_path / "trex-core-image.tar.sha256"
image_id = tmp_path / "trex-core-image.id"
archive.write_bytes(b"tested image")
image_release.write_checksum(archive, checksum)
image_id.write_text(IMAGE_ID + "\n", encoding="ascii")
return archive, checksum, image_id
def test_release_tags_are_deterministic_and_never_latest():
release = image_release.release_tags(COMMIT, "push", "refs/tags/v1.2.3")
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, "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():
runner = RecordingRunner()
image_release.smoke_image("candidate", runner, timeout=17, migration_dir=MIGRATION_DIR)
runs = [call for call in runner.commands if call[0][:2] == ["docker", "run"]]
removals = [call for call in runner.commands if call[0][:2] == ["docker", "rm"]]
expected_migrations = tuple(sorted(path.name for path in MIGRATION_DIR.glob("*.sql")))
assert expected_migrations
assert len(runs) == len(removals) == 5
assert [call[0][call[0].index("--entrypoint") + 1 :] for call in runs] == [
["python", "candidate", "-c", "import trex_core"],
["trex-core-daemon", "candidate", "--help"],
["trex-core", "candidate", "--help"],
["trex-core-reset", "candidate", "--help"],
["python", "candidate", "-I", "-c", image_release.migration_inventory_code(expected_migrations)],
]
assert all("--network" in call[0] and "none" in call[0] for call in runs)
assert all("--read-only" in call[0] and "--cap-drop" in call[0] for call in runs)
assert all(call[2] == 17 for call in runs + removals)
assert all("--config" not in call[0] and "--yes" not in call[0] for call in runs)
def test_smoke_rejects_empty_canonical_migration_inventory(tmp_path):
with pytest.raises(ValueError, match="canonical migration inventory is empty"):
image_release.smoke_image("candidate", RecordingRunner(), migration_dir=tmp_path)
def test_migration_inventory_probe_failure_propagates_after_cleanup():
class FifthProbeFailsRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
run_count = len([call for call in self.commands if call[0][:2] == ["docker", "run"]])
if list(command)[:2] == ["docker", "run"] and run_count == 4:
self.commands.append((list(command), input_text, timeout))
raise subprocess.CalledProcessError(1, command)
return super().run(command, input_text=input_text, timeout=timeout)
runner = FifthProbeFailsRunner()
with pytest.raises(RuntimeError, match="smoke probe 5 failed"):
image_release.smoke_image("candidate", runner, migration_dir=MIGRATION_DIR)
assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"]
def test_smoke_failure_propagates_after_cleanup():
runner = RecordingRunner(fail_on=("docker", "run"))
with pytest.raises(RuntimeError, match="smoke probe 1 failed"):
image_release.smoke_image("candidate", runner)
assert [call[0][:3] for call in runner.commands] == [
["docker", "run", "--name"],
["docker", "rm", "--force"],
]
def test_smoke_fails_if_captured_probe_output_contains_canary():
class LeakingRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
result = super().run(command, input_text=input_text, timeout=timeout)
if list(command)[:2] == ["docker", "run"]:
return image_release.Result(image_release.CANARY.decode())
return result
runner = LeakingRunner()
with pytest.raises(RuntimeError, match="smoke probe 1 failed"):
image_release.smoke_image("candidate", runner)
assert runner.commands[-1][0][:3] == ["docker", "rm", "--force"]
def test_mutated_archive_stops_before_load_or_registry(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
archive.write_bytes(b"mutated")
runner = RecordingRunner()
with pytest.raises(ValueError, match="checksum mismatch"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
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,
)
assert runner.commands == []
def test_load_failure_stops_before_login_and_push(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
runner = RecordingRunner(fail_on=("docker", "load"))
with pytest.raises(subprocess.CalledProcessError):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
)
assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands)
def test_revision_mismatch_stops_before_login_and_push(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
runner = RecordingRunner(revision="c" * 40)
with pytest.raises(ValueError, match="revision does not match"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
)
assert not any(call[0][:2] in (["docker", "login"], ["docker", "push"]) for call in runner.commands)
def test_expected_image_id_mismatch_stops_before_credentials_tags_or_push(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
runner = RecordingRunner(image_id="sha256:" + "d" * 64)
with pytest.raises(ValueError, match="image ID does not match"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
)
assert not any(
call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"])
for call in runner.commands
)
def test_unrelated_load_output_cannot_use_stale_expected_local_tag(tmp_path):
archive, checksum, image_id = _artifact(tmp_path)
runner = RecordingRunner(loaded_image="unrelated:image", image_id=IMAGE_ID)
with pytest.raises(ValueError, match="did not report the expected local image"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
)
assert not any(
call[0][:2] in (["docker", "tag"], ["docker", "login"], ["docker", "push"])
for call in runner.commands
)
@pytest.mark.parametrize(
"invalid_id",
["", "sha256:" + "c" * 63, "sha256:" + "C" * 64, "sha512:" + "c" * 64],
)
def test_invalid_tested_image_id_stops_before_load(tmp_path, invalid_id):
archive, checksum, image_id = _artifact(tmp_path)
image_id.write_text(invalid_id, encoding="ascii")
runner = RecordingRunner()
with pytest.raises(ValueError, match="image ID evidence"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
)
assert runner.commands == []
def test_record_image_id_writes_strict_inspected_id(tmp_path):
output = tmp_path / "trex-core-image.id"
runner = RecordingRunner()
assert image_release.write_image_id("candidate", output, runner) == IMAGE_ID
assert output.read_text(encoding="ascii") == IMAGE_ID + "\n"
assert runner.commands == [
(["docker", "image", "inspect", "--format", "{{.Id}}", "candidate"], None, None)
]
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"
runner = RecordingRunner()
digest = image_release.publish_image(
archive=archive,
checksum=checksum,
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",
runner=runner,
output_path=output,
summary_path=summary,
)
commands = [call[0][:2] for call in runner.commands]
assert commands.index(["docker", "load"]) < commands.index(["docker", "login"])
assert commands.count(["docker", "push"]) == 2
assert digest == DIGEST
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])
def test_invalid_post_push_digest_emits_no_success_evidence(tmp_path, bad_digest):
archive, checksum, image_id = _artifact(tmp_path)
output = tmp_path / "output"
summary = tmp_path / "summary"
runner = RecordingRunner(digest=bad_digest)
with pytest.raises(ValueError, match="valid digest"):
image_release.publish_image(
archive=archive,
checksum=checksum,
image_id=image_id,
local_image=f"trex-core-ci:{COMMIT}",
commit=COMMIT,
event_name="push",
ref="refs/tags/v1.2.3",
actor="actor",
token="fake-token",
runner=runner,
output_path=output,
summary_path=summary,
)
assert any(call[0][:2] == ["docker", "push"] for call in runner.commands)
assert not output.exists()
assert not summary.exists()
def test_release_second_push_failure_preserves_nonzero_without_success_evidence(tmp_path):
class SecondPushFailsRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
result = super().run(command, input_text=input_text, timeout=timeout)
pushes = [call for call in self.commands if call[0][:2] == ["docker", "push"]]
if list(command)[:2] == ["docker", "push"] and len(pushes) == 2:
return image_release.Result("digest unavailable\n")
return result
archive, checksum, image_id = _artifact(tmp_path)
output = tmp_path / "output"
summary = tmp_path / "summary"
runner = SecondPushFailsRunner()
with pytest.raises(ValueError, match="valid digest"):
image_release.publish_image(
archive=archive,
checksum=checksum,
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",
runner=runner,
output_path=output,
summary_path=summary,
)
assert len([call for call in runner.commands if call[0][:2] == ["docker", "push"]]) == 2
assert not output.exists()
assert not summary.exists()
def test_canary_is_found_inside_nested_export_archive(tmp_path):
layer = io.BytesIO()
with tarfile.open(fileobj=layer, mode="w") as archive:
payload = image_release.CANARY
member = tarfile.TarInfo("app/data")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
saved = tmp_path / "image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = layer.getvalue()
member = tarfile.TarInfo("layer.tar")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
def test_canary_is_found_inside_compressed_digest_named_blob(tmp_path):
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w") as archive:
payload = image_release.CANARY
member = tarfile.TarInfo("app/data")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
saved = tmp_path / "oci-image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = gzip.compress(inner.getvalue())
member = tarfile.TarInfo("blobs/sha256/deadbeef")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
def test_canary_is_found_in_symlink_metadata_inside_compressed_digest_blob(tmp_path):
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w") as archive:
member = tarfile.TarInfo("app/link")
member.type = tarfile.SYMTYPE
member.linkname = image_release.CANARY.decode()
archive.addfile(member)
saved = tmp_path / "oci-image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = gzip.compress(inner.getvalue())
member = tarfile.TarInfo("blobs/sha256/deadbeef")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
@pytest.mark.parametrize(
"pax_headers",
[
{"comment": image_release.CANARY.decode()},
{image_release.CANARY.decode(): "safe"},
],
)
def test_canary_is_found_in_pax_metadata_inside_compressed_digest_blob(tmp_path, pax_headers):
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w", format=tarfile.PAX_FORMAT) as archive:
member = tarfile.TarInfo("app/data")
member.pax_headers = pax_headers
archive.addfile(member)
saved = tmp_path / "oci-image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = gzip.compress(inner.getvalue())
member = tarfile.TarInfo("blobs/sha256/deadbeef")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
@pytest.mark.parametrize("field", ["uname", "gname"])
def test_canary_is_found_in_owner_metadata_inside_compressed_digest_blob(tmp_path, field):
inner = io.BytesIO()
with tarfile.open(fileobj=inner, mode="w") as archive:
member = tarfile.TarInfo("app/data")
setattr(member, field, image_release.CANARY.decode())
archive.addfile(member)
saved = tmp_path / "oci-image.tar"
with tarfile.open(saved, mode="w") as archive:
payload = gzip.compress(inner.getvalue())
member = tarfile.TarInfo("blobs/sha256/deadbeef")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
with pytest.raises(ValueError, match="canary marker found"):
image_release._scan_tar(saved)
def test_archive_scan_rejects_excessive_nesting_depth(tmp_path):
payload = io.BytesIO()
with tarfile.open(fileobj=payload, mode="w"):
pass
nested = payload.getvalue()
for _ in range(4):
wrapped = io.BytesIO()
with tarfile.open(fileobj=wrapped, mode="w") as archive:
member = tarfile.TarInfo("blob")
member.size = len(nested)
archive.addfile(member, io.BytesIO(nested))
nested = wrapped.getvalue()
path = tmp_path / "deep.tar"
path.write_bytes(nested)
with pytest.raises(ValueError, match="archive scan resource limit exceeded") as error:
image_release._scan_tar(path, image_release._ScanBudget(max_depth=2))
assert image_release.CANARY.decode() not in str(error.value)
def test_archive_scan_rejects_excessive_aggregate_members(tmp_path):
path = tmp_path / "members.tar"
with tarfile.open(path, mode="w") as archive:
for index in range(3):
member = tarfile.TarInfo(f"directory-{index}")
member.type = tarfile.DIRTYPE
archive.addfile(member)
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
image_release._scan_tar(path, image_release._ScanBudget(max_members=2))
def test_archive_scan_rejects_excessive_aggregate_extracted_bytes(tmp_path):
path = tmp_path / "aggregate.tar"
with tarfile.open(path, mode="w") as archive:
payload = b"123456"
member = tarfile.TarInfo("payload")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
budget = image_release._ScanBudget(max_extracted_bytes=5, max_member_bytes=10)
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
image_release._scan_tar(path, budget)
def test_archive_scan_rejects_excessive_per_member_bytes(tmp_path):
path = tmp_path / "member.tar"
with tarfile.open(path, mode="w") as archive:
payload = b"123456"
member = tarfile.TarInfo("payload")
member.size = len(payload)
archive.addfile(member, io.BytesIO(payload))
budget = image_release._ScanBudget(max_extracted_bytes=100, max_member_bytes=5)
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
image_release._scan_tar(path, budget)
def test_archive_scan_enforces_top_level_input_bytes_and_accepts_boundary(tmp_path):
path = tmp_path / "input.tar"
with tarfile.open(path, mode="w"):
pass
input_size = path.stat().st_size
image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size))
with pytest.raises(ValueError, match="archive scan resource limit exceeded"):
image_release._scan_tar(path, image_release._ScanBudget(max_input_bytes=input_size - 1))
def test_canary_failure_message_does_not_disclose_marker(tmp_path):
output = tmp_path / "captured-output"
output.write_bytes(image_release.CANARY)
with pytest.raises(ValueError) as error:
image_release.assert_clean_output([output])
assert image_release.CANARY.decode() not in str(error.value)
def test_canary_scan_checks_metadata_filesystem_archive_and_cleanup(tmp_path):
class ScanRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
result = super().run(command, input_text=input_text, timeout=timeout)
if list(command)[:2] == ["docker", "export"]:
output = Path(command[command.index("--output") + 1])
with tarfile.open(output, mode="w"):
pass
return result
saved = tmp_path / "image.tar"
with tarfile.open(saved, mode="w"):
pass
runner = ScanRunner()
image_release.scan_image("candidate", saved, runner)
commands = [call[0] for call in runner.commands]
assert ["docker", "history", "--no-trunc", "candidate"] in commands
assert ["docker", "image", "inspect", "candidate"] in commands
assert ["docker", "create", "--network", "none", "candidate"] in commands
assert any(command[:2] == ["docker", "export"] for command in commands)
assert ["docker", "rm", "--force", "container-id"] in commands
def test_canary_in_create_stderr_still_cleans_container(tmp_path):
class LeakingCreateRunner(RecordingRunner):
def run(self, command, *, input_text=None, timeout=None):
result = super().run(command, input_text=input_text, timeout=timeout)
if list(command)[:2] == ["docker", "create"]:
return image_release.Result("container-id\n", image_release.CANARY.decode())
return result
saved = tmp_path / "image.tar"
with tarfile.open(saved, mode="w"):
pass
runner = LeakingCreateRunner()
with pytest.raises(ValueError, match="canary marker found"):
image_release.scan_image("candidate", saved, runner)
assert runner.commands[-1][0] == ["docker", "rm", "--force", "container-id"]