diff --git a/.github/workflows/publish-eval-image.yml b/.github/workflows/publish-eval-image.yml new file mode 100644 index 0000000..4336c02 --- /dev/null +++ b/.github/workflows/publish-eval-image.yml @@ -0,0 +1,228 @@ +# Publish the measured eval image: +# ghcr.io/baseintelligence/agent-challenge-canonical +# +# Consumes golden assets via ORAS (never from this git tree): +# ghcr.io/baseintelligence/agent-challenge-golden-assets@sha256:… +# +# Co-pin AGENT_CHALLENGE_REF (git SHA or tag of BaseIntelligence/agent-challenge) +# with GOLDEN_ASSETS_REF (digest-pinned OCI artefact). Update both together. +# +# Package visibility: after the first successful push, set the GHCR package +# "agent-challenge-canonical" to Public (GitHub → Packages → package settings) +# so miners can `docker pull` by digest without credentials. DSTACK_DOCKER_* +# remains available for private pulls but is not required for this image. +# +# Bootstrap: until the first golden-assets digest exists, set GOLDEN_ASSETS_REF +# via workflow_dispatch input after running agent-challenge publish-golden-assets. + +name: Publish eval image + +on: + workflow_dispatch: + inputs: + agent_challenge_ref: + description: "agent-challenge git ref (SHA or tag), co-pinned with golden assets" + required: true + type: string + golden_assets_ref: + description: "Digest-pinned golden assets (ghcr.io/.../agent-challenge-golden-assets@sha256:...)" + required: true + type: string + image_tag: + description: "Optional extra tag (e.g. v1.2.3); sha- always applied" + required: false + default: "" + type: string + push: + description: "Push to GHCR (false = build-only smoke)" + required: true + default: true + type: boolean + push: + tags: + - "eval-image-v*.*.*" + +permissions: + contents: read + +env: + IMAGE_NAME: ghcr.io/baseintelligence/agent-challenge-canonical + # Default co-pins — replace GOLDEN_ASSETS_REF after first ORAS publish (T1 bootstrap). + # AGENT_CHALLENGE_REF must match the freeze that produced the golden-assets digest. + AGENT_CHALLENGE_REF: ${{ inputs.agent_challenge_ref || 'main' }} + GOLDEN_ASSETS_REF: ${{ inputs.golden_assets_ref || 'ghcr.io/baseintelligence/agent-challenge-golden-assets@sha256:REPLACE_AFTER_FIRST_PUBLISH' }} + SOURCE_DATE_EPOCH: "1700000000" + +jobs: + publish-eval-image: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout agent-recipe + uses: actions/checkout@v4 + + - name: Require digest-pinned GOLDEN_ASSETS_REF + env: + REF: ${{ env.GOLDEN_ASSETS_REF }} + run: | + set -euo pipefail + case "$REF" in + *@sha256:[0-9a-f][0-9a-f]*) + echo "GOLDEN_ASSETS_REF ok: $REF" + ;; + *) + echo "::error::GOLDEN_ASSETS_REF must be digest-pinned (repo@sha256:64hex); got: $REF" + echo "Publish golden assets first (agent-challenge publish-golden-assets), then re-run with the digest." + exit 1 + ;; + esac + + - name: Install ORAS + uses: oras-project/setup-oras@v1 + with: + version: "1.2.2" + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Pull golden assets (digest-pinned ORAS) + env: + GOLDEN_ASSETS_REF: ${{ env.GOLDEN_ASSETS_REF }} + run: | + set -euo pipefail + DEST="${RUNNER_TEMP}/golden-assets" + mkdir -p "$DEST" + oras pull "$GOLDEN_ASSETS_REF" -o "$DEST" + test -f "$DEST/MANIFEST.json" + test -f "$DEST/golden/dataset-digest.json" + test -f "$DEST/golden/live-registry-refs.json" + test -f "$DEST/golden/tbench-2.1-oracle.json.enc" + test -d "$DEST/docker/canonical/live-task-cache" + python3 - <<'PY' + import hashlib, json, os, sys + from pathlib import Path + dest = Path(os.environ["RUNNER_TEMP"]) / "golden-assets" + manifest = json.loads((dest / "MANIFEST.json").read_text()) + assert manifest.get("schema") == "agent-challenge/golden-assets@1", manifest.get("schema") + errors = [] + for rel, expected in manifest.get("files", {}).items(): + path = dest / rel + if not path.is_file(): + errors.append(f"missing {rel}") + continue + got = "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest() + if got != expected: + errors.append(f"digest mismatch {rel}: {got} != {expected}") + if errors: + print("\n".join(errors), file=sys.stderr) + sys.exit(1) + print("MANIFEST.json file digests ok; version=", manifest.get("version")) + PY + echo "GOLDEN_ASSETS_DIR=$DEST" >> "$GITHUB_ENV" + + - name: Checkout agent-challenge at co-pinned ref + uses: actions/checkout@v4 + with: + repository: BaseIntelligence/agent-challenge + ref: ${{ env.AGENT_CHALLENGE_REF }} + path: ${{ runner.temp }}/agent-challenge + # GITHUB_TOKEN can read public repos; for private, use a PAT with contents:read. + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Record co-pin metadata + id: pins + run: | + set -euo pipefail + AC_SHA=$(git -C "${RUNNER_TEMP}/agent-challenge" rev-parse HEAD) + echo "agent_challenge_sha=$AC_SHA" >> "$GITHUB_OUTPUT" + echo "AGENT_CHALLENGE_SHA=$AC_SHA" >> "$GITHUB_ENV" + echo "co-pin AGENT_CHALLENGE_REF=${AGENT_CHALLENGE_REF} -> $AC_SHA" + echo "co-pin GOLDEN_ASSETS_REF=${GOLDEN_ASSETS_REF}" + + - name: Set up Docker Buildx (docker-container driver) + uses: docker/setup-buildx-action@v3 + with: + driver: docker-container + + - name: Build (and optionally push) eval image + id: build + env: + PUSH: ${{ inputs.push == true || inputs.push == 'true' || github.event_name == 'push' }} + EXTRA_TAG: ${{ inputs.image_tag }} + run: | + set -euo pipefail + META="${RUNNER_TEMP}/build-metadata.json" + TAGS=(-t "${IMAGE_NAME}:sha-${AGENT_CHALLENGE_SHA}") + if [[ -n "${EXTRA_TAG}" ]]; then + TAGS+=(-t "${IMAGE_NAME}:${EXTRA_TAG}") + fi + if [[ "${GITHUB_REF_TYPE:-}" == "tag" ]]; then + TAGS+=(-t "${IMAGE_NAME}:${GITHUB_REF_NAME}") + fi + + OUTPUT_ARGS=() + if [[ "${PUSH}" == "true" ]]; then + # Single primary tag for rewrite-timestamp push; retag after if needed. + PRIMARY="${IMAGE_NAME}:sha-${AGENT_CHALLENGE_SHA}" + OUTPUT_ARGS=(--output "type=image,name=${PRIMARY},push=true,rewrite-timestamp=true") + if [[ -n "${EXTRA_TAG}" ]]; then + OUTPUT_ARGS+=(--output "type=image,name=${IMAGE_NAME}:${EXTRA_TAG},push=true,rewrite-timestamp=true") + fi + else + OUTPUT_ARGS=(--output "type=oci,dest=${RUNNER_TEMP}/eval-image.oci.tar,rewrite-timestamp=true") + fi + + docker buildx build \ + --progress=plain \ + -f Dockerfile \ + --provenance=false \ + --sbom=false \ + --build-arg "SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH}" \ + --build-context "golden_assets=${GOLDEN_ASSETS_DIR}" \ + --build-context "agent_challenge=${RUNNER_TEMP}/agent-challenge" \ + --metadata-file "${META}" \ + "${OUTPUT_ARGS[@]}" \ + . + + DIGEST=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("containerimage.digest",""))' "${META}") + if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::missing containerimage.digest in build metadata: ${DIGEST}" + cat "${META}" || true + exit 1 + fi + echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" + echo "image_ref=${IMAGE_NAME}@${DIGEST}" >> "$GITHUB_OUTPUT" + echo "Built ${IMAGE_NAME}@${DIGEST}" + echo "### Eval image" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- Image: \`${IMAGE_NAME}@${DIGEST}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- AGENT_CHALLENGE_SHA: \`${AGENT_CHALLENGE_SHA}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- GOLDEN_ASSETS_REF: \`${GOLDEN_ASSETS_REF}\`" >> "$GITHUB_STEP_SUMMARY" + echo "- SOURCE_DATE_EPOCH: \`${SOURCE_DATE_EPOCH}\`" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Set GHCR package **agent-challenge-canonical** visibility to **Public** after first push." >> "$GITHUB_STEP_SUMMARY" + + - name: Emit digest artefact + if: always() && steps.build.outputs.digest != '' + run: | + mkdir -p "${RUNNER_TEMP}/out" + { + echo "image_ref=${{ steps.build.outputs.image_ref }}" + echo "digest=${{ steps.build.outputs.digest }}" + echo "agent_challenge_sha=${{ steps.pins.outputs.agent_challenge_sha }}" + echo "golden_assets_ref=${GOLDEN_ASSETS_REF}" + } > "${RUNNER_TEMP}/out/eval-image-pins.txt" + cat "${RUNNER_TEMP}/out/eval-image-pins.txt" + + - name: Upload pin receipt + if: always() && steps.build.outputs.digest != '' + uses: actions/upload-artifact@v4 + with: + name: eval-image-pins + path: ${{ runner.temp }}/out/eval-image-pins.txt diff --git a/.gitignore b/.gitignore index dcdad68..7bb647d 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,12 @@ secrets/ *.zip submissions/ .local/ + +# Eval-image local build inputs (ORAS pull / multi-checkout — never commit) +golden/ +live-task-cache/ +**/live-task-cache/ +golden-assets/ +/tmp-golden-assets/ +*.oci.tar +.eval-image-pins.txt diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbeaf91 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,55 @@ +# syntax=docker/dockerfile:1 +# +# Measured agent-challenge eval / orchestrator image. +# Owned by agent-recipe (D7). Built reproducibly; published as +# ghcr.io/baseintelligence/agent-challenge-canonical +# +# Golden + live-task-cache are NEVER taken from this git tree. They arrive only +# via BuildKit named context `golden_assets` (ORAS pull of +# ghcr.io/baseintelligence/agent-challenge-golden-assets@sha256:… per T0 §5). +# +# agent_challenge package source arrives via named context `agent_challenge` +# (git SHA checkout co-pinned with GOLDEN_ASSETS_REF). +# +# Reproducibility (match agent_challenge.canonical.build): +# SOURCE_DATE_EPOCH=1700000000 docker buildx build \ +# --build-arg SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH \ +# --provenance=false --sbom=false \ +# --build-context golden_assets=/path/to/pulled-assets \ +# --build-context agent_challenge=/path/to/agent-challenge@SHA \ +# --output type=image,name=ghcr.io/baseintelligence/agent-challenge-canonical:TAG,push=true,rewrite-timestamp=true \ +# -f Dockerfile . +# +# No secrets: ciphertext golden only; CHALLENGE_GOLDEN_KEY_FILE never baked. + +ARG BASE_IMAGE=python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf +FROM ${BASE_IMAGE} + +# Declared so BuildKit normalises layer timestamps (rewrite-timestamp). +ARG SOURCE_DATE_EPOCH + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/src + +WORKDIR /app + +# Locked + hashed runtime deps from the co-pinned agent-challenge tree. +COPY --from=agent_challenge docker/canonical/requirements.txt /app/canonical-requirements.txt +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --no-compile --require-hashes --requirement /app/canonical-requirements.txt + +COPY --from=agent_challenge pyproject.toml uv.lock README.md /app/ +COPY --from=agent_challenge src /app/src + +# Golden assets from ORAS-pulled build-context only (T0 §5.4). +COPY --from=golden_assets golden/dataset-digest.json \ + golden/live-registry-refs.json \ + golden/tbench-2.1-oracle.json.enc \ + /opt/agent-challenge/golden/ +RUN ln -sfn /opt/agent-challenge/golden /app/golden +COPY --from=golden_assets docker/canonical/live-task-cache/ \ + /opt/agent-challenge/task-cache/ + +ENTRYPOINT ["python", "-m", "agent_challenge.canonical.entrypoint"] +CMD ["--help"] diff --git a/README.md b/README.md index a4814a1..434b614 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,10 @@ dry-run paths only. Never print secrets. | Self-deploy module | `python -m agent_challenge.selfdeploy` | | Challenge repo | [BaseIntelligence/agent-challenge](https://github.com/BaseIntelligence/agent-challenge) | | Measured OpenRouter model (docs pin) | `x-ai/grok-4.5` | +| Measured eval image | `ghcr.io/baseintelligence/agent-challenge-canonical` (digest-only pins) | +| Golden assets (build input) | `ghcr.io/baseintelligence/agent-challenge-golden-assets@sha256:…` via `GOLDEN_ASSETS_REF` | + +See **[docs/eval-image.md](docs/eval-image.md)** for Dockerfile / ORAS co-pin (`AGENT_CHALLENGE_REF` + `GOLDEN_ASSETS_REF`) and reproducible build flags. | Default Phala money cap | `$20` (CPU TDX; prefer `tdx.small`) | | Public API | `https://chain.joinbase.ai` | | Twin recipe (PRISM) | [BaseIntelligence/prism-recipe](https://github.com/BaseIntelligence/prism-recipe) | diff --git a/docs/eval-image.md b/docs/eval-image.md new file mode 100644 index 0000000..247c543 --- /dev/null +++ b/docs/eval-image.md @@ -0,0 +1,59 @@ +# Measured eval image (agent-recipe) + +This repository owns the **Dockerfile + GHCR publish workflow** for the measured +agent-challenge eval / orchestrator image (plan D7 / T0–T1). + +| Image | Purpose | +| --- | --- | +| `ghcr.io/baseintelligence/agent-challenge-canonical` | Runtime eval image (digest-only pins in compose / validator) | +| `ghcr.io/baseintelligence/agent-challenge-golden-assets` | **Build input only** (ORAS artefact). Owned and published by [agent-challenge](https://github.com/BaseIntelligence/agent-challenge). | + +## Co-pins (required) + +Builds must set **both** of these together (same freeze): + +| Env | Meaning | +| --- | --- | +| `AGENT_CHALLENGE_REF` | Git SHA or tag of `BaseIntelligence/agent-challenge` (source + `docker/canonical/requirements.txt`) | +| `GOLDEN_ASSETS_REF` | Digest-pinned OCI ref, e.g. `ghcr.io/baseintelligence/agent-challenge-golden-assets@sha256:…` | + +Never pin golden assets by floating tag alone. Never commit `golden/` or +`live-task-cache/` into this git tree. + +## Local / CI build shape + +```bash +# 1) Pull golden assets (after agent-challenge has published them) +oras pull "$GOLDEN_ASSETS_REF" -o /tmp/golden-assets + +# 2) Checkout agent-challenge at the co-pinned SHA +git clone https://github.com/BaseIntelligence/agent-challenge.git /tmp/agent-challenge +git -C /tmp/agent-challenge checkout "$AGENT_CHALLENGE_REF" + +# 3) Reproducible buildx (matches agent_challenge.canonical.build) +export SOURCE_DATE_EPOCH=1700000000 +docker buildx build \ + --provenance=false --sbom=false \ + --build-arg SOURCE_DATE_EPOCH=$SOURCE_DATE_EPOCH \ + --build-context golden_assets=/tmp/golden-assets \ + --build-context agent_challenge=/tmp/agent-challenge \ + --output type=image,name=ghcr.io/baseintelligence/agent-challenge-canonical:local,push=false,rewrite-timestamp=true \ + -f Dockerfile . +``` + +Workflow: [`.github/workflows/publish-eval-image.yml`](../.github/workflows/publish-eval-image.yml). + +## Package visibility + +After the first successful GHCR push, set package +**agent-challenge-canonical** to **Public** so miners can pull by digest without +credentials. Ciphertext golden may appear in image layers (miner-visible by +design); the decryption key never ships in git or the image. + +## ENTRYPOINT + +Unchanged from agent-challenge canonical: + +```text +["python", "-m", "agent_challenge.canonical.entrypoint"] +``` diff --git a/pyproject.toml b/pyproject.toml index 98bd25b..fc19489 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,7 @@ name = "agent-recipe" version = "0.1.0" description = "Miner-facing agent-challenge recipe: baseagent template + Phala self-deploy attestation path" readme = "README.md" -requires-python = ">=3.11" +requires-python = ">=3.12" license = { text = "Apache-2.0" } authors = [ { name = "BaseIntelligence" }, @@ -37,7 +37,7 @@ pythonpath = ["src"] [tool.ruff] line-length = 100 -target-version = "py311" +target-version = "py312" [tool.ruff.lint] select = ["E", "F", "I", "UP", "B"] diff --git a/tests/test_eval_image_contract.py b/tests/test_eval_image_contract.py new file mode 100644 index 0000000..60ace49 --- /dev/null +++ b/tests/test_eval_image_contract.py @@ -0,0 +1,205 @@ +"""Contract tests for the measured eval image surface (T1 / T0 §5). + +Scenarios: + S1 — Dockerfile ENTRYPOINT + digest-pinned base + golden via build-context + S2 — Never COPY golden/ from agent-recipe tree; never docker.io + S3 — publish-eval-image workflow co-pins refs + reproducible build flags + S5 — Python 3.12 alignment in pyproject +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from agent_recipe.config import repo_root + +BASE_DIGEST = ( + "python:3.12-slim@sha256:423ed6ab25b1921a477529254bfeeabf5855151dc2c3141699a1bfc852199fbf" +) +IMAGE_NAME = "ghcr.io/baseintelligence/agent-challenge-canonical" +GOLDEN_ASSETS_NAME = "ghcr.io/baseintelligence/agent-challenge-golden-assets" +ENTRYPOINT_JSON = '["python", "-m", "agent_challenge.canonical.entrypoint"]' +DIGEST_PIN_RE = re.compile(r"@sha256:[0-9a-f]{64}") +# Forbidden: COPY golden/ from the default build context (repo tree). +# Allowed: COPY --from=golden_assets ... golden/... +_FORBIDDEN_COPY_GOLDEN_FROM_REPO = re.compile( + r"^\s*COPY\s+(?!.*--from=)(?=.*\bgolden/)", + re.MULTILINE, +) +_FORBIDDEN_COPY_TASK_CACHE_FROM_REPO = re.compile( + r"^\s*COPY\s+(?!.*--from=)(?=.*live-task-cache)", + re.MULTILINE, +) + + +@pytest.fixture(scope="module") +def root() -> Path: + return repo_root() + + +@pytest.fixture(scope="module") +def dockerfile(root: Path) -> Path: + path = root / "Dockerfile" + assert path.is_file(), "agent-recipe/Dockerfile must exist (T1 eval image)" + return path + + +@pytest.fixture(scope="module") +def dockerfile_text(dockerfile: Path) -> str: + return dockerfile.read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def workflow_path(root: Path) -> Path: + path = root / ".github" / "workflows" / "publish-eval-image.yml" + assert path.is_file(), "publish-eval-image.yml must exist" + return path + + +@pytest.fixture(scope="module") +def workflow_text(workflow_path: Path) -> str: + return workflow_path.read_text(encoding="utf-8") + + +# --- S1 happy path ----------------------------------------------------------- + + +def test_dockerfile_base_image_is_digest_pinned(dockerfile_text: str) -> None: + assert BASE_DIGEST in dockerfile_text + assert "ARG BASE_IMAGE=" in dockerfile_text + # Default BASE_IMAGE value must carry @sha256: + m = re.search(r"ARG\s+BASE_IMAGE=(\S+)", dockerfile_text) + assert m is not None + assert DIGEST_PIN_RE.search(m.group(1)), m.group(1) + + +def test_dockerfile_entrypoint_is_canonical_module(dockerfile_text: str) -> None: + # JSON-exec form required (no shell). + assert f"ENTRYPOINT {ENTRYPOINT_JSON}" in dockerfile_text.replace(" ", "").replace( + ENTRYPOINT_JSON.replace(" ", ""), "" + ) or 'ENTRYPOINT ["python", "-m", "agent_challenge.canonical.entrypoint"]' in dockerfile_text + assert "agent_challenge.canonical.entrypoint" in dockerfile_text + + +def test_dockerfile_copies_golden_only_from_named_build_context(dockerfile_text: str) -> None: + assert "COPY --from=golden_assets" in dockerfile_text + assert "dataset-digest.json" in dockerfile_text + assert "live-registry-refs.json" in dockerfile_text + assert "tbench-2.1-oracle.json.enc" in dockerfile_text + assert "/opt/agent-challenge/golden/" in dockerfile_text + assert "/opt/agent-challenge/task-cache/" in dockerfile_text + assert "ln -sfn /opt/agent-challenge/golden /app/golden" in dockerfile_text + + +def test_dockerfile_obtains_agent_challenge_from_build_context(dockerfile_text: str) -> None: + """Orchestrator code comes from pinned agent_challenge context, not vendored forever.""" + assert "COPY --from=agent_challenge" in dockerfile_text + assert "canonical-requirements.txt" in dockerfile_text or "requirements.txt" in dockerfile_text + assert "COPY --from=agent_challenge src" in dockerfile_text or re.search( + r"COPY\s+--from=agent_challenge\s+src\b", dockerfile_text + ) + + +# --- S2 edge: never bake golden from this repo -------------------------------- + + +def test_dockerfile_must_not_copy_golden_from_repo_context(dockerfile_text: str) -> None: + bad = _FORBIDDEN_COPY_GOLDEN_FROM_REPO.findall(dockerfile_text) + assert bad == [], f"forbidden COPY golden/ from repo context: {bad}" + bad_cache = _FORBIDDEN_COPY_TASK_CACHE_FROM_REPO.findall(dockerfile_text) + assert bad_cache == [], f"forbidden COPY live-task-cache from repo: {bad_cache}" + + +def test_dockerfile_and_workflow_have_no_docker_io(dockerfile_text: str, workflow_text: str) -> None: + for label, text in (("Dockerfile", dockerfile_text), ("workflow", workflow_text)): + assert "docker.io" not in text, f"docker.io forbidden in {label} (D6)" + + +def test_no_golden_payload_tracked_in_agent_recipe(root: Path) -> None: + """Golden ciphertext / task-cache must never land in agent-recipe git tree.""" + forbidden_names = { + "dataset-digest.json", + "live-registry-refs.json", + "tbench-2.1-oracle.json.enc", + "tbench-2.1-oracle.json", + } + skip = {".git", ".venv", "__pycache__", ".pytest_cache", "node_modules"} + hits: list[str] = [] + for path in root.rglob("*"): + if not path.is_file(): + continue + if any(part in skip for part in path.parts): + continue + if path.name in forbidden_names: + hits.append(str(path.relative_to(root))) + if "live-task-cache" in path.parts and path.suffix not in {".py", ".md", ".yml"}: + # Allow docs mentioning the name; forbid actual cache trees. + if path.suffix == "" or path.parent.name not in {"docs", "tests"}: + if "live-task-cache" in {p for p in path.parts}: + # Only flag if under a live-task-cache directory that looks like payload + if any(p == "live-task-cache" for p in path.parts): + rel = path.relative_to(root) + if rel.parts[0] not in {"docs", "tests", ".github"}: + hits.append(str(rel)) + assert hits == [], f"golden/task-cache payload must not be tracked: {hits}" + + +# --- S3 workflow contract ----------------------------------------------------- + + +def test_workflow_targets_canonical_ghcr_image(workflow_text: str) -> None: + assert IMAGE_NAME in workflow_text + assert GOLDEN_ASSETS_NAME in workflow_text + assert "packages: write" in workflow_text or "packages:write" in workflow_text.replace(" ", "") + + +def test_workflow_co_pins_agent_challenge_and_golden_assets(workflow_text: str) -> None: + assert "AGENT_CHALLENGE_REF" in workflow_text + assert "GOLDEN_ASSETS_REF" in workflow_text + # Build must pin golden by digest (placeholder ok until first publish). + assert "@sha256:" in workflow_text or "sha256:" in workflow_text + + +def test_workflow_reproducible_build_flags(workflow_text: str) -> None: + assert "SOURCE_DATE_EPOCH" in workflow_text + assert "1700000000" in workflow_text + assert "provenance=false" in workflow_text or "provenance: false" in workflow_text + assert "sbom=false" in workflow_text or "sbom: false" in workflow_text + assert "rewrite-timestamp" in workflow_text + + +def test_workflow_pulls_golden_via_oras_not_git_copy(workflow_text: str) -> None: + assert "oras pull" in workflow_text + assert "--build-context" in workflow_text + assert "golden_assets" in workflow_text + assert "agent_challenge" in workflow_text + + +def test_workflow_documents_public_package(workflow_text: str) -> None: + lowered = workflow_text.lower() + assert "public" in lowered + assert "package" in lowered + + +# --- S5 python 3.12 ----------------------------------------------------------- + + +def test_pyproject_requires_python_312(root: Path) -> None: + text = (root / "pyproject.toml").read_text(encoding="utf-8") + assert 'requires-python = ">=3.12"' in text + assert 'target-version = "py312"' in text + + +def test_docs_mention_golden_assets_ref_pin(root: Path) -> None: + candidates = [ + root / "README.md", + root / "docs" / "architecture.md", + root / "docs" / "eval-image.md", + ] + blob = "\n".join(p.read_text(encoding="utf-8") for p in candidates if p.is_file()) + assert "GOLDEN_ASSETS_REF" in blob + assert GOLDEN_ASSETS_NAME in blob or "agent-challenge-golden-assets" in blob + assert IMAGE_NAME in blob or "agent-challenge-canonical" in blob diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..3f7b35b --- /dev/null +++ b/uv.lock @@ -0,0 +1,188 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "agent-recipe" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, +] +provides-extras = ["dev", "selfdeploy"] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, + { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, + { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, + { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, + { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, + { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, + { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, + { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, + { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, + { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +]