feat(release): add verified GHCR image publication

This commit is contained in:
Hermes Agent
2026-08-10 13:26:57 +09:00
parent 21f7e7de77
commit fe46f8b237
9 changed files with 1406 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
.git
.env
.env.*
config/
.proofline/
.github/
.pytest_cache/
.ruff_cache/
.venv/
docs/
scripts/
tests/
temp/
__pycache__/
*.egg-info/
*.pyc
+128
View File
@@ -0,0 +1,128 @@
name: Publish verified container image
on:
push:
branches:
- main
tags:
- "v*"
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out exact source commit
uses: actions/checkout@v4
with:
ref: ${{ github.sha }}
fetch-depth: 0
- 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 ".[dev]"
- name: Run repository lint
run: ruff check .
- name: Run repository tests
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 }}
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/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}"
+24
View File
@@ -0,0 +1,24 @@
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 ./
COPY trex_core/ ./trex_core/
RUN python -m pip install --no-cache-dir . \
&& useradd --create-home --uid 10001 trex
USER 10001:10001
ENTRYPOINT ["trex-core-daemon"]
+7
View File
@@ -40,6 +40,13 @@ pkill -TERM -f trex-core-daemon
- `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지. - `config/.env`는 **라이브 KIS 키 + portfolio_hub 토큰 + 라이브 PostgreSQL DSN**을 담는다 — 커밋 금지, 라이브 DB에 파괴적 작업 금지.
- 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN. - 테스트는 `TREX_BROKER=paper` + 파생 `trex_core_test` DSN.
### 컨테이너 설정, 배포 및 rollback
- 이미지는 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과 분리된 승인 절차로 수행한다.
--- ---
## 2. 운영자 CLI ## 2. 운영자 CLI
+1
View File
@@ -14,6 +14,7 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"pytest>=8", "pytest>=8",
"PyYAML>=6",
"ruff>=0.4", "ruff>=0.4",
] ]
+465
View File
@@ -0,0 +1,465 @@
#!/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}$")
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 release_tags(commit: 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")
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 _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) -> None:
for index, probe in enumerate(SMOKE_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,
ref: str,
actor: str,
token: str,
runner: Runner,
output_path: Path | None = None,
summary_path: Path | None = None,
) -> str:
tags = release_tags(commit, 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}\n")
_append(
summary_path,
"## Published trex-core image\n\n"
f"- Image: `{reference}`\n"
f"- Digest: `{digest}`\n"
f"- Source commit: `{commit}`\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)
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("--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 == "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,
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
+153
View File
@@ -0,0 +1,153 @@
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_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
+564
View File
@@ -0,0 +1,564 @@
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
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[: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():
main = image_release.release_tags(COMMIT, "refs/heads/main")
release = image_release.release_tags(COMMIT, "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)
@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")
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
runner = RecordingRunner()
image_release.smoke_image("candidate", runner, timeout=17)
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"]]
assert len(runs) == len(removals) == 4
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"],
]
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_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,
ref="refs/heads/main",
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,
ref="refs/heads/main",
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,
ref="refs/heads/main",
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,
ref="refs/heads/main",
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,
ref="refs/heads/main",
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,
ref="refs/heads/main",
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_digest_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,
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 f"digest={DIGEST}" in output.read_text()
assert COMMIT in output.read_text()
assert f"{image_release.IMAGE}@{DIGEST}" in summary.read_text()
@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,
ref="refs/heads/main",
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,
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"]
Generated
+48
View File
@@ -207,6 +207,52 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
] ]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]] [[package]]
name = "redis" name = "redis"
version = "8.0.0" version = "8.0.0"
@@ -256,6 +302,7 @@ dependencies = [
[package.optional-dependencies] [package.optional-dependencies]
dev = [ dev = [
{ name = "pytest" }, { name = "pytest" },
{ name = "pyyaml" },
{ name = "ruff" }, { name = "ruff" },
] ]
@@ -266,6 +313,7 @@ requires-dist = [
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.1" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" },
{ name = "python-dotenv", specifier = ">=1.0" }, { name = "python-dotenv", specifier = ">=1.0" },
{ name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6" },
{ name = "redis", specifier = ">=5" }, { name = "redis", specifier = ">=5" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4" },
] ]