merge: integrate issue 57 image publication
This commit is contained in:
@@ -2,8 +2,6 @@ name: Publish verified container image
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
@@ -16,12 +14,26 @@ jobs:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
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
|
- name: Check out exact source commit
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
fetch-depth: 0
|
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
|
- name: Set up Python 3.12
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
@@ -108,6 +120,7 @@ jobs:
|
|||||||
image: ${{ steps.publish.outputs.image }}
|
image: ${{ steps.publish.outputs.image }}
|
||||||
digest: ${{ steps.publish.outputs.digest }}
|
digest: ${{ steps.publish.outputs.digest }}
|
||||||
source_commit: ${{ steps.publish.outputs.source_commit }}
|
source_commit: ${{ steps.publish.outputs.source_commit }}
|
||||||
|
version_tag: ${{ steps.publish.outputs.version_tag }}
|
||||||
steps:
|
steps:
|
||||||
- name: Download tested image artifact
|
- name: Download tested image artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -127,4 +140,5 @@ jobs:
|
|||||||
--image-id tested-image/trex-core-image.id \
|
--image-id tested-image/trex-core-image.id \
|
||||||
--local-image "trex-core-ci:${GITHUB_SHA}" \
|
--local-image "trex-core-ci:${GITHUB_SHA}" \
|
||||||
--commit "${GITHUB_SHA}" \
|
--commit "${GITHUB_SHA}" \
|
||||||
|
--event-name "${GITHUB_EVENT_NAME}" \
|
||||||
--ref "${GITHUB_REF}"
|
--ref "${GITHUB_REF}"
|
||||||
|
|||||||
+5
-2
@@ -13,12 +13,15 @@ RUN test "${#SOURCE_COMMIT}" -eq 40 \
|
|||||||
esac \
|
esac \
|
||||||
|| { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; }
|
|| { echo "SOURCE_COMMIT must be a 40-character lowercase commit" >&2; exit 1; }
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
COPY pyproject.toml uv.lock ./
|
||||||
COPY trex_core/ ./trex_core/
|
COPY trex_core/ ./trex_core/
|
||||||
|
|
||||||
RUN python -m pip install --no-cache-dir . \
|
RUN python -m pip install --no-cache-dir "uv==0.11.14" \
|
||||||
|
&& uv sync --locked --no-dev --no-editable \
|
||||||
&& useradd --create-home --uid 10001 trex
|
&& useradd --create-home --uid 10001 trex
|
||||||
|
|
||||||
|
ENV PATH="/opt/trex-core/.venv/bin:${PATH}"
|
||||||
|
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
|
|
||||||
ENTRYPOINT ["trex-core-daemon"]
|
ENTRYPOINT ["trex-core-daemon"]
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ pkill -TERM -f trex-core-daemon
|
|||||||
|
|
||||||
### 컨테이너 설정, 배포 및 rollback
|
### 컨테이너 설정, 배포 및 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에 기록하지 않는다.
|
- 이미지는 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한다.
|
- 게시된 `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하지 않는다.
|
- rollback은 이전에 검증하고 기록한 digest reference로 workload image를 되돌린 뒤 정상적인 배포 절차로 재시작한다. tag를 다시 해석하거나 source에서 다시 build하지 않는다.
|
||||||
|
|||||||
+36
-15
@@ -20,6 +20,7 @@ CANARY = b"TREX_SECRET_CANARY_DO_NOT_SHIP"
|
|||||||
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
COMMIT_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||||
DIGEST_RE = re.compile(r"(?:^|\s)digest:\s*(sha256:[0-9a-f]{64})(?:\s|$)")
|
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}$")
|
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 = (
|
DEFAULT_MIGRATION_DIR = (
|
||||||
Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql"
|
Path(__file__).resolve().parents[1] / "trex_core" / "adapters" / "persistence" / "sql"
|
||||||
)
|
)
|
||||||
@@ -100,19 +101,26 @@ def validate_commit(commit: str) -> str:
|
|||||||
return commit
|
return commit
|
||||||
|
|
||||||
|
|
||||||
def release_tags(commit: str, ref: str) -> list[str]:
|
def validate_publication_trigger(event_name: str, ref: str) -> str:
|
||||||
|
if event_name != "push":
|
||||||
|
raise ValueError("publication event must be a push")
|
||||||
|
if not VERSION_TAG_RE.fullmatch(ref):
|
||||||
|
raise ValueError("publication ref must be a strict vX.Y.Z tag")
|
||||||
|
return ref.removeprefix("refs/tags/")
|
||||||
|
|
||||||
|
|
||||||
|
def verify_checkout(commit: str, runner: Runner) -> str:
|
||||||
|
expected = validate_commit(commit)
|
||||||
|
actual = runner.run(["git", "rev-parse", "HEAD"]).stdout.strip()
|
||||||
|
if actual != expected:
|
||||||
|
raise ValueError("checked out HEAD does not match source commit")
|
||||||
|
return actual
|
||||||
|
|
||||||
|
|
||||||
|
def release_tags(commit: str, event_name: str, ref: str) -> list[str]:
|
||||||
validate_commit(commit)
|
validate_commit(commit)
|
||||||
tags = [f"{IMAGE}:{commit[:12]}"]
|
release = validate_publication_trigger(event_name, ref)
|
||||||
if ref == "refs/heads/main":
|
return [f"{IMAGE}:{commit[:12]}", f"{IMAGE}:{release}"]
|
||||||
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:
|
def sha256_file(path: Path) -> str:
|
||||||
@@ -359,6 +367,7 @@ def publish_image(
|
|||||||
image_id: Path,
|
image_id: Path,
|
||||||
local_image: str,
|
local_image: str,
|
||||||
commit: str,
|
commit: str,
|
||||||
|
event_name: str,
|
||||||
ref: str,
|
ref: str,
|
||||||
actor: str,
|
actor: str,
|
||||||
token: str,
|
token: str,
|
||||||
@@ -366,7 +375,8 @@ def publish_image(
|
|||||||
output_path: Path | None = None,
|
output_path: Path | None = None,
|
||||||
summary_path: Path | None = None,
|
summary_path: Path | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
tags = release_tags(commit, ref)
|
tags = release_tags(commit, event_name, ref)
|
||||||
|
version_tag = validate_publication_trigger(event_name, ref)
|
||||||
verify_checksum(archive, checksum)
|
verify_checksum(archive, checksum)
|
||||||
expected_image_id = read_image_id(image_id)
|
expected_image_id = read_image_id(image_id)
|
||||||
loaded = runner.run(["docker", "load", "--input", str(archive)])
|
loaded = runner.run(["docker", "load", "--input", str(archive)])
|
||||||
@@ -406,13 +416,17 @@ def publish_image(
|
|||||||
|
|
||||||
digest = digests[0]
|
digest = digests[0]
|
||||||
reference = f"{IMAGE}@{digest}"
|
reference = f"{IMAGE}@{digest}"
|
||||||
_append(output_path, f"image={reference}\ndigest={digest}\nsource_commit={commit}\n")
|
_append(
|
||||||
|
output_path,
|
||||||
|
f"image={reference}\ndigest={digest}\nsource_commit={commit}\nversion_tag={version_tag}\n",
|
||||||
|
)
|
||||||
_append(
|
_append(
|
||||||
summary_path,
|
summary_path,
|
||||||
"## Published trex-core image\n\n"
|
"## Published trex-core image\n\n"
|
||||||
f"- Image: `{reference}`\n"
|
f"- Image: `{reference}`\n"
|
||||||
f"- Digest: `{digest}`\n"
|
f"- Digest: `{digest}`\n"
|
||||||
f"- Source commit: `{commit}`\n",
|
f"- Source commit: `{commit}`\n"
|
||||||
|
f"- Version tag: `{version_tag}`\n",
|
||||||
)
|
)
|
||||||
return digest
|
return digest
|
||||||
|
|
||||||
@@ -425,6 +439,9 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
smoke.add_argument("--image", required=True)
|
smoke.add_argument("--image", required=True)
|
||||||
smoke.add_argument("--timeout", type=int, default=30)
|
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 = commands.add_parser("scan")
|
||||||
scan.add_argument("--image", required=True)
|
scan.add_argument("--image", required=True)
|
||||||
scan.add_argument("--archive", type=Path, required=True)
|
scan.add_argument("--archive", type=Path, required=True)
|
||||||
@@ -450,6 +467,7 @@ def _parser() -> argparse.ArgumentParser:
|
|||||||
publish.add_argument("--image-id", type=Path, required=True)
|
publish.add_argument("--image-id", type=Path, required=True)
|
||||||
publish.add_argument("--local-image", required=True)
|
publish.add_argument("--local-image", required=True)
|
||||||
publish.add_argument("--commit", required=True)
|
publish.add_argument("--commit", required=True)
|
||||||
|
publish.add_argument("--event-name", required=True)
|
||||||
publish.add_argument("--ref", required=True)
|
publish.add_argument("--ref", required=True)
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
@@ -459,6 +477,8 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
runner = SubprocessRunner()
|
runner = SubprocessRunner()
|
||||||
if args.command == "smoke":
|
if args.command == "smoke":
|
||||||
smoke_image(args.image, runner, args.timeout)
|
smoke_image(args.image, runner, args.timeout)
|
||||||
|
elif args.command == "verify-checkout":
|
||||||
|
verify_checkout(args.commit, runner)
|
||||||
elif args.command == "scan":
|
elif args.command == "scan":
|
||||||
scan_image(args.image, args.archive, runner)
|
scan_image(args.image, args.archive, runner)
|
||||||
elif args.command == "checksum":
|
elif args.command == "checksum":
|
||||||
@@ -476,6 +496,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|||||||
image_id=args.image_id,
|
image_id=args.image_id,
|
||||||
local_image=args.local_image,
|
local_image=args.local_image,
|
||||||
commit=args.commit,
|
commit=args.commit,
|
||||||
|
event_name=args.event_name,
|
||||||
ref=args.ref,
|
ref=args.ref,
|
||||||
actor=os.environ.get("GITHUB_ACTOR", ""),
|
actor=os.environ.get("GITHUB_ACTOR", ""),
|
||||||
token=os.environ.get("REGISTRY_TOKEN", ""),
|
token=os.environ.get("REGISTRY_TOKEN", ""),
|
||||||
|
|||||||
@@ -17,10 +17,43 @@ def _step_index(steps, name):
|
|||||||
return next(index for index, step in enumerate(steps) if step.get("name") == name)
|
return next(index for index, step in enumerate(steps) if step.get("name") == name)
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_triggers_only_main_and_v_tags():
|
def test_workflow_triggers_only_v_tag_push_candidates():
|
||||||
workflow = _workflow()
|
workflow = _workflow()
|
||||||
assert set(workflow["on"]) == {"push"}
|
assert set(workflow["on"]) == {"push"}
|
||||||
assert workflow["on"]["push"] == {"branches": ["main"], "tags": ["v*"]}
|
assert workflow["on"]["push"] == {"tags": ["v*"]}
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_rejects_non_strict_version_events_before_checkout_and_build():
|
||||||
|
steps = _workflow()["jobs"]["verify"]["steps"]
|
||||||
|
guard = steps[0]
|
||||||
|
assert guard["name"] == "Reject non-version publication events"
|
||||||
|
assert guard["env"] == {
|
||||||
|
"PUBLICATION_EVENT": "${{ github.event_name }}",
|
||||||
|
"PUBLICATION_REF": "${{ github.ref }}",
|
||||||
|
}
|
||||||
|
assert '"${PUBLICATION_EVENT}" != "push"' in guard["run"]
|
||||||
|
strict_ref = (
|
||||||
|
r"^refs/tags/v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$"
|
||||||
|
)
|
||||||
|
assert strict_ref in guard["run"]
|
||||||
|
assert _step_index(steps, guard["name"]) < _step_index(steps, "Check out exact source commit")
|
||||||
|
assert _step_index(steps, guard["name"]) < _step_index(steps, "Build source image once")
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_reverifies_exact_checked_out_commit_before_repository_or_registry_work():
|
||||||
|
workflow = _workflow()
|
||||||
|
verify_steps = workflow["jobs"]["verify"]["steps"]
|
||||||
|
publish_steps = workflow["jobs"]["publish"]["steps"]
|
||||||
|
checkout_index = _step_index(verify_steps, "Check out exact source commit")
|
||||||
|
reverify_index = _step_index(verify_steps, "Reverify exact checked out commit")
|
||||||
|
|
||||||
|
assert checkout_index < reverify_index < _step_index(verify_steps, "Install repository checks")
|
||||||
|
assert verify_steps[reverify_index]["run"] == (
|
||||||
|
'python scripts/image_release.py verify-checkout --commit "${GITHUB_SHA}"'
|
||||||
|
)
|
||||||
|
assert reverify_index < _step_index(verify_steps, "Build source image once")
|
||||||
|
assert all("REGISTRY_TOKEN" not in str(step) for step in verify_steps[: reverify_index + 1])
|
||||||
|
assert _step_index(publish_steps, "Verify, load, and publish without rebuilding") > 0
|
||||||
|
|
||||||
|
|
||||||
def test_publish_is_gated_and_has_the_only_package_write_permission():
|
def test_publish_is_gated_and_has_the_only_package_write_permission():
|
||||||
@@ -34,6 +67,16 @@ def test_publish_is_gated_and_has_the_only_package_write_permission():
|
|||||||
assert "packages" not in verify["permissions"]
|
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():
|
def test_verify_canary_build_smoke_export_scan_checksum_upload_contract():
|
||||||
steps = _workflow()["jobs"]["verify"]["steps"]
|
steps = _workflow()["jobs"]["verify"]["steps"]
|
||||||
names = [step["name"] for step in steps]
|
names = [step["name"] for step in steps]
|
||||||
@@ -123,6 +166,7 @@ def test_publish_job_has_only_download_then_verified_publish_helper():
|
|||||||
--image-id tested-image/trex-core-image.id \\
|
--image-id tested-image/trex-core-image.id \\
|
||||||
--local-image "trex-core-ci:${GITHUB_SHA}" \\
|
--local-image "trex-core-ci:${GITHUB_SHA}" \\
|
||||||
--commit "${GITHUB_SHA}" \\
|
--commit "${GITHUB_SHA}" \\
|
||||||
|
--event-name "${GITHUB_EVENT_NAME}" \\
|
||||||
--ref "${GITHUB_REF}"'''
|
--ref "${GITHUB_REF}"'''
|
||||||
assert steps[1]["run"].strip() == expected_run
|
assert steps[1]["run"].strip() == expected_run
|
||||||
|
|
||||||
@@ -148,7 +192,10 @@ def test_workflow_uses_full_sha_and_contains_no_latest_tag():
|
|||||||
def test_docker_context_contract():
|
def test_docker_context_contract():
|
||||||
dockerfile = (REPO / "Dockerfile").read_text()
|
dockerfile = (REPO / "Dockerfile").read_text()
|
||||||
copy_lines = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("COPY ")]
|
copy_lines = [line.strip() for line in dockerfile.splitlines() if line.strip().startswith("COPY ")]
|
||||||
assert copy_lines == ["COPY pyproject.toml ./", "COPY trex_core/ ./trex_core/"]
|
assert copy_lines == ["COPY pyproject.toml uv.lock ./", "COPY trex_core/ ./trex_core/"]
|
||||||
|
assert 'python -m pip install --no-cache-dir "uv==0.11.14"' in dockerfile
|
||||||
|
assert "uv sync --locked --no-dev --no-editable" in dockerfile
|
||||||
|
assert "pip install --no-cache-dir ." not in dockerfile
|
||||||
assert "org.opencontainers.image.revision" in dockerfile
|
assert "org.opencontainers.image.revision" in dockerfile
|
||||||
assert "python:3.12" in dockerfile
|
assert "python:3.12" in dockerfile
|
||||||
|
|
||||||
@@ -158,6 +205,9 @@ def test_docker_context_contract():
|
|||||||
|
|
||||||
def test_operator_documentation_preserves_rollback_boundaries():
|
def test_operator_documentation_preserves_rollback_boundaries():
|
||||||
operations = (REPO / "docs" / "operations.md").read_text()
|
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 "read-only volume" in operations
|
||||||
assert "runtime environment" in operations
|
assert "runtime environment" in operations
|
||||||
assert "sha256:<64-hex>" in operations
|
assert "sha256:<64-hex>" in operations
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ class RecordingRunner:
|
|||||||
self.commands.append((command, input_text, timeout))
|
self.commands.append((command, input_text, timeout))
|
||||||
if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on):
|
if self.fail_on and command[: len(self.fail_on)] == list(self.fail_on):
|
||||||
raise subprocess.CalledProcessError(1, command)
|
raise subprocess.CalledProcessError(1, command)
|
||||||
|
if command == ["git", "rev-parse", "HEAD"]:
|
||||||
|
return image_release.Result(self.revision + "\n")
|
||||||
if command[:2] == ["docker", "load"]:
|
if command[:2] == ["docker", "load"]:
|
||||||
return image_release.Result(f"Loaded image: {self.loaded_image}\n")
|
return image_release.Result(f"Loaded image: {self.loaded_image}\n")
|
||||||
if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command:
|
if command[:3] == ["docker", "image", "inspect"] and "{{.Id}}" in command:
|
||||||
@@ -64,18 +66,65 @@ def _artifact(tmp_path: Path) -> tuple[Path, Path, Path]:
|
|||||||
|
|
||||||
|
|
||||||
def test_release_tags_are_deterministic_and_never_latest():
|
def test_release_tags_are_deterministic_and_never_latest():
|
||||||
main = image_release.release_tags(COMMIT, "refs/heads/main")
|
release = image_release.release_tags(COMMIT, "push", "refs/tags/v1.2.3")
|
||||||
release = image_release.release_tags(COMMIT, "refs/tags/v1.2.3")
|
|
||||||
|
|
||||||
assert main == [f"{image_release.IMAGE}:{COMMIT[:12]}"]
|
assert release == [
|
||||||
assert release == [*main, f"{image_release.IMAGE}:v1.2.3"]
|
f"{image_release.IMAGE}:{COMMIT[:12]}",
|
||||||
assert all(not tag.endswith(":latest") for tag in main + release)
|
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, ""])
|
@pytest.mark.parametrize("commit", ["A" * 40, "a" * 39, "g" * 40, ""])
|
||||||
def test_invalid_source_commit_is_rejected(commit):
|
def test_invalid_source_commit_is_rejected(commit):
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
image_release.release_tags(commit, "refs/heads/main")
|
image_release.release_tags(commit, "push", "refs/tags/v1.2.3")
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_checkout_requires_exact_git_head_match():
|
||||||
|
runner = RecordingRunner()
|
||||||
|
|
||||||
|
assert image_release.verify_checkout(COMMIT, runner) == COMMIT
|
||||||
|
assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_checkout_mismatch_fails_closed():
|
||||||
|
runner = RecordingRunner(revision="c" * 40)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="checked out HEAD does not match source commit"):
|
||||||
|
image_release.verify_checkout(COMMIT, runner)
|
||||||
|
assert runner.commands == [(["git", "rev-parse", "HEAD"], None, None)]
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
|
def test_smoke_uses_exact_isolated_probes_and_cleans_containers():
|
||||||
@@ -156,7 +205,38 @@ def test_mutated_archive_stops_before_load_or_registry(tmp_path):
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
ref="refs/heads/main",
|
event_name="push",
|
||||||
|
ref="refs/tags/v1.2.3",
|
||||||
|
actor="actor",
|
||||||
|
token="fake-token",
|
||||||
|
runner=runner,
|
||||||
|
)
|
||||||
|
assert runner.commands == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("event_name", "ref"),
|
||||||
|
[
|
||||||
|
("workflow_dispatch", "refs/tags/v1.2.3"),
|
||||||
|
("push", "refs/heads/main"),
|
||||||
|
("push", "refs/tags/v01.2.3"),
|
||||||
|
("push", "refs/tags/v1.2.3-rc.1"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_publication_trigger_stops_before_artifact_load_or_registry(
|
||||||
|
tmp_path, event_name, ref
|
||||||
|
):
|
||||||
|
runner = RecordingRunner()
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="publication"):
|
||||||
|
image_release.publish_image(
|
||||||
|
archive=tmp_path / "missing.tar",
|
||||||
|
checksum=tmp_path / "missing.sha256",
|
||||||
|
image_id=tmp_path / "missing.id",
|
||||||
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
|
commit=COMMIT,
|
||||||
|
event_name=event_name,
|
||||||
|
ref=ref,
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
runner=runner,
|
runner=runner,
|
||||||
@@ -175,7 +255,8 @@ def test_load_failure_stops_before_login_and_push(tmp_path):
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
ref="refs/heads/main",
|
event_name="push",
|
||||||
|
ref="refs/tags/v1.2.3",
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
runner=runner,
|
runner=runner,
|
||||||
@@ -194,7 +275,8 @@ def test_revision_mismatch_stops_before_login_and_push(tmp_path):
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
ref="refs/heads/main",
|
event_name="push",
|
||||||
|
ref="refs/tags/v1.2.3",
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
runner=runner,
|
runner=runner,
|
||||||
@@ -213,7 +295,8 @@ def test_expected_image_id_mismatch_stops_before_credentials_tags_or_push(tmp_pa
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
ref="refs/heads/main",
|
event_name="push",
|
||||||
|
ref="refs/tags/v1.2.3",
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
runner=runner,
|
runner=runner,
|
||||||
@@ -235,7 +318,8 @@ def test_unrelated_load_output_cannot_use_stale_expected_local_tag(tmp_path):
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
ref="refs/heads/main",
|
event_name="push",
|
||||||
|
ref="refs/tags/v1.2.3",
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
runner=runner,
|
runner=runner,
|
||||||
@@ -262,7 +346,8 @@ def test_invalid_tested_image_id_stops_before_load(tmp_path, invalid_id):
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
ref="refs/heads/main",
|
event_name="push",
|
||||||
|
ref="refs/tags/v1.2.3",
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
runner=runner,
|
runner=runner,
|
||||||
@@ -280,7 +365,7 @@ def test_record_image_id_writes_strict_inspected_id(tmp_path):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_valid_publish_records_digest_only_after_push(tmp_path):
|
def test_valid_publish_records_exact_release_evidence_only_after_push(tmp_path):
|
||||||
archive, checksum, image_id = _artifact(tmp_path)
|
archive, checksum, image_id = _artifact(tmp_path)
|
||||||
output = tmp_path / "output"
|
output = tmp_path / "output"
|
||||||
summary = tmp_path / "summary"
|
summary = tmp_path / "summary"
|
||||||
@@ -292,6 +377,7 @@ def test_valid_publish_records_digest_only_after_push(tmp_path):
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
|
event_name="push",
|
||||||
ref="refs/tags/v2.0.0",
|
ref="refs/tags/v2.0.0",
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
@@ -304,9 +390,19 @@ def test_valid_publish_records_digest_only_after_push(tmp_path):
|
|||||||
assert commands.index(["docker", "load"]) < commands.index(["docker", "login"])
|
assert commands.index(["docker", "load"]) < commands.index(["docker", "login"])
|
||||||
assert commands.count(["docker", "push"]) == 2
|
assert commands.count(["docker", "push"]) == 2
|
||||||
assert digest == DIGEST
|
assert digest == DIGEST
|
||||||
assert f"digest={DIGEST}" in output.read_text()
|
assert output.read_text() == (
|
||||||
assert COMMIT in output.read_text()
|
f"image={image_release.IMAGE}@{DIGEST}\n"
|
||||||
assert f"{image_release.IMAGE}@{DIGEST}" in summary.read_text()
|
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])
|
@pytest.mark.parametrize("bad_digest", ["", "sha256:" + "b" * 63, "sha512:" + "b" * 64])
|
||||||
@@ -323,7 +419,8 @@ def test_invalid_post_push_digest_emits_no_success_evidence(tmp_path, bad_digest
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
ref="refs/heads/main",
|
event_name="push",
|
||||||
|
ref="refs/tags/v1.2.3",
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
runner=runner,
|
runner=runner,
|
||||||
@@ -356,6 +453,7 @@ def test_release_second_push_failure_preserves_nonzero_without_success_evidence(
|
|||||||
image_id=image_id,
|
image_id=image_id,
|
||||||
local_image=f"trex-core-ci:{COMMIT}",
|
local_image=f"trex-core-ci:{COMMIT}",
|
||||||
commit=COMMIT,
|
commit=COMMIT,
|
||||||
|
event_name="push",
|
||||||
ref="refs/tags/v2.0.0",
|
ref="refs/tags/v2.0.0",
|
||||||
actor="actor",
|
actor="actor",
|
||||||
token="fake-token",
|
token="fake-token",
|
||||||
|
|||||||
Reference in New Issue
Block a user