From d36a6d8cc1fbe79c676c21811aca85d2fe0f4dfa Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 22 Jul 2026 04:34:33 +0200 Subject: [PATCH 1/2] ci: publish exact no-send candidate through WIF --- .github/workflows/ci.yml | 1 + .../workflows/gcp-leoclean-nosend-release.yml | 284 ++++++++++++++++++ ...st_gcp_leoclean_nosend_release_workflow.py | 146 +++++++++ 3 files changed, 431 insertions(+) create mode 100644 .github/workflows/gcp-leoclean-nosend-release.yml create mode 100644 tests/test_gcp_leoclean_nosend_release_workflow.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5a85d5..9c0eeb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,6 +100,7 @@ jobs: tests/test_decision_engine_replay.py \ tests/test_evaluate_agent_routing.py \ tests/test_gcp_artifact_workflow.py \ + tests/test_gcp_leoclean_nosend_release_workflow.py \ tests/test_capture_vps_canonical_postgres_snapshot.py \ tests/test_gcp_infra_execute_canary.py \ tests/test_gcp_infra_readiness_checker.py \ diff --git a/.github/workflows/gcp-leoclean-nosend-release.yml b/.github/workflows/gcp-leoclean-nosend-release.yml new file mode 100644 index 0000000..a953203 --- /dev/null +++ b/.github/workflows/gcp-leoclean-nosend-release.yml @@ -0,0 +1,284 @@ +name: gcp-leoclean-nosend-release + +on: + workflow_dispatch: + +permissions: + contents: read + id-token: write + +concurrency: + group: gcp-leoclean-nosend-release-main + cancel-in-progress: false + +env: + PROJECT_ID: teleo-501523 + REGION: europe-west6 + ARTIFACT_REPOSITORY: teleo + IMAGE_REPOSITORY: europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging + WORKLOAD_IDENTITY_PROVIDER: projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github + ARTIFACT_SERVICE_ACCOUNT: sa-artifact-builder@teleo-501523.iam.gserviceaccount.com + +jobs: + publish: + name: Build, smoke, and publish exact no-send release + runs-on: ubuntu-latest + timeout-minutes: 90 + env: + RELEASE_ROOT: ${{ runner.temp }}/leoclean-nosend-release + RUNTIME_ARTIFACT: ${{ runner.temp }}/leoclean-nosend-release/runtime-artifact + RUNTIME_RECEIPT: ${{ runner.temp }}/leoclean-nosend-release/evidence/runtime-receipt.json + OCI_SMOKE_RECEIPT: ${{ runner.temp }}/leoclean-nosend-release/evidence/oci-smoke.json + IMAGE_CONTEXT: ${{ runner.temp }}/leoclean-nosend-release/image-context + BUILD_PUSH_RECEIPT: ${{ runner.temp }}/leoclean-nosend-release/receipts/build-push-receipt.json + RELEASE_BUNDLE: ${{ runner.temp }}/leoclean-nosend-release/final/release-v3 + DOCKER_CONFIG: ${{ runner.temp }}/leoclean-nosend-release/docker-config + steps: + - name: Require manual main dispatch + shell: bash + run: | + set -euo pipefail + test "${GITHUB_EVENT_NAME}" = "workflow_dispatch" + test "${GITHUB_REF}" = "refs/heads/main" + test "${GITHUB_REF_TYPE}" = "branch" + + - name: Check out exact dispatched revision + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ github.sha }} + + - name: Verify exact clean main revision + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse --verify HEAD)" = "${GITHUB_SHA}" + test "$(git rev-parse --verify refs/remotes/origin/main^{commit})" = "${GITHUB_SHA}" + test -z "$(git status --porcelain=v1 --untracked-files=all)" + + - name: Set up exact Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 + with: + python-version: "3.11.9" + + - name: Install exact no-send build tools + shell: bash + run: | + set -euo pipefail + python -m pip install --disable-pip-version-check 'uv==0.9.30' 'PyYAML==6.0.3' + test "$(uv --version | awk '{print $2}')" = "0.9.30" + test "$(command -v docker)" = "/usr/bin/docker" + + - name: Verify fixed staging package target + shell: bash + run: | + set -euo pipefail + python - <<'PY' + import os + + from ops import gcp_leoclean_nosend_package as package + + assert package.PROJECT == "teleo-501523" + assert package.PLATFORM == "linux/amd64" + assert os.environ["IMAGE_REPOSITORY"] == package.IMAGE_REPOSITORY + assert package.IMAGE_REPOSITORY == "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging" + PY + + - name: Pull digest-pinned OCI bases + shell: bash + run: | + set -euo pipefail + docker pull --platform=linux/amd64 \ + "ghcr.io/astral-sh/uv:0.9.30@sha256:538e0b39736e7feae937a65983e49d2ab75e1559d35041f9878b7b7e51de91e4" + docker pull --platform=linux/amd64 \ + "docker.io/library/python:3.11.9-slim-bookworm@sha256:8fb099199b9f2d70342674bd9dbccd3ed03a258f26bbd1d556822c6dfc60c317" + docker pull --platform=linux/amd64 \ + "docker.io/library/postgres:16.14-bookworm@sha256:92620daddcd947f8d5ab5ba66e848702fe443d87fed30c4cea8e389fd78dfc55" + + - name: Compile and release-verify exact runtime artifact + shell: bash + run: | + set -euo pipefail + install -d -m 0700 "${RELEASE_ROOT}" "${RELEASE_ROOT}/evidence" + LEOCLEAN_NOSEND_RESULTS_DIR="${RELEASE_ROOT}/evidence" \ + LEOCLEAN_NOSEND_RECEIPT="${RUNTIME_RECEIPT}" \ + LEOCLEAN_NOSEND_ARTIFACT_OUTPUT="${RUNTIME_ARTIFACT}" \ + scripts/run_leoclean_nosend_runtime_canary.sh + + - name: Smoke exact no-send OCI surface + shell: bash + run: | + set -euo pipefail + python scripts/run_gcp_leoclean_nosend_oci_smoke.py \ + --repo-root "${GITHUB_WORKSPACE}" \ + --artifact "${RUNTIME_ARTIFACT}" \ + --runtime-receipt "${RUNTIME_RECEIPT}" \ + --output "${OCI_SMOKE_RECEIPT}" + + - name: Prepare exact candidate context + shell: bash + run: | + set -euo pipefail + identity_contract="${RELEASE_ROOT}/identity-runtime-contract.json" + identity_manifest="${RELEASE_ROOT}/identity-manifest.json" + identity_bundle="${RELEASE_ROOT}/identity" + + python ops/gcp_leoclean_nosend_package.py identity-contract \ + --repo-root "${GITHUB_WORKSPACE}" \ + --artifact "${RUNTIME_ARTIFACT}" \ + --receipt "${RUNTIME_RECEIPT}" \ + --output "${identity_contract}" + python ops/gcp_leoclean_nosend_package.py generate-identity-manifest \ + --repo-root "${GITHUB_WORKSPACE}" \ + --artifact "${RUNTIME_ARTIFACT}" \ + --receipt "${RUNTIME_RECEIPT}" \ + --database-fingerprint fixtures/working-leo/leo-identity-v1/leo-database-fingerprint-v1.json \ + --constitution fixtures/working-leo/leo-identity-v1/leo-constitution-v1.json \ + --database-identity fixtures/working-leo/leo-identity-v1/leo-database-identity-v1.json \ + --identity-source-root "${GITHUB_WORKSPACE}" \ + --output "${identity_manifest}" + python ops/gcp_leoclean_nosend_package.py compile-identity \ + --repo-root "${GITHUB_WORKSPACE}" \ + --artifact "${RUNTIME_ARTIFACT}" \ + --receipt "${RUNTIME_RECEIPT}" \ + --identity-manifest "${identity_manifest}" \ + --identity-source-root "${GITHUB_WORKSPACE}" \ + --output "${identity_bundle}" + python ops/gcp_leoclean_nosend_package.py prepare \ + --repo-root "${GITHUB_WORKSPACE}" \ + --artifact "${RUNTIME_ARTIFACT}" \ + --receipt "${RUNTIME_RECEIPT}" \ + --identity "${identity_bundle}" \ + --identity-source-root "${GITHUB_WORKSPACE}" \ + --output "${IMAGE_CONTEXT}" + + python - "${OCI_SMOKE_RECEIPT}" "${IMAGE_CONTEXT}/image-input.json" "${GITHUB_SHA}" <<'PY' + import json + import sys + from pathlib import Path + + smoke = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + image_input = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) + revision = sys.argv[3] + assert smoke["status"] == "pass" + assert smoke["cleanup"]["status"] == "pass" + assert smoke["teleo_git_head"] == revision + assert image_input["runtime"]["teleo_git_head"] == revision + assert smoke["input_sha256"] == image_input["input_sha256"] + assert smoke["identity_sha256"] == image_input["identity"]["bundle_sha256"] + PY + + - name: Authenticate to Google Cloud through WIF + id: auth + uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed + with: + workload_identity_provider: ${{ env.WORKLOAD_IDENTITY_PROVIDER }} + service_account: ${{ env.ARTIFACT_SERVICE_ACCOUNT }} + create_credentials_file: true + export_environment_variables: true + + - name: Set up exact gcloud + uses: google-github-actions/setup-gcloud@e427ad8a34f8676edf47cf7d7925499adf3eb74f + with: + version: "550.0.0" + + - name: Require immutable fixed Artifact Registry repository + shell: bash + run: | + set -euo pipefail + repository_readback="${RUNNER_TEMP}/leoclean-nosend-repository.json" + umask 077 + gcloud artifacts repositories describe "${ARTIFACT_REPOSITORY}" \ + --project "${PROJECT_ID}" \ + --location "${REGION}" \ + --format json >"${repository_readback}" + python - "${repository_readback}" <<'PY' + import json + import sys + from pathlib import Path + + repository = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + assert repository["name"] == ( + "projects/teleo-501523/locations/europe-west6/repositories/teleo" + ) + assert repository["format"] == "DOCKER" + assert repository["dockerConfig"]["immutableTags"] is True + PY + rm -f -- "${repository_readback}" + + - name: Configure private Artifact Registry Docker auth + shell: bash + run: | + set -euo pipefail + install -d -m 0700 "${DOCKER_CONFIG}" + gcloud auth configure-docker "${REGION}-docker.pkg.dev" --quiet + test -f "${DOCKER_CONFIG}/config.json" + test ! -L "${DOCKER_CONFIG}/config.json" + chmod 0600 "${DOCKER_CONFIG}/config.json" + + - name: Build and push one immutable candidate + shell: bash + run: | + set -euo pipefail + install -d -m 0700 "$(dirname "${BUILD_PUSH_RECEIPT}")" + python ops/gcp_leoclean_nosend_package.py build-push \ + --repo-root "${GITHUB_WORKSPACE}" \ + --image-context "${IMAGE_CONTEXT}" \ + --docker-binary /usr/bin/docker \ + --docker-host unix:///var/run/docker.sock \ + --docker-config "${DOCKER_CONFIG}" \ + --output-receipt "${BUILD_PUSH_RECEIPT}" \ + --execute-staging-push + + - name: Finalize and validate release v3 bundle + shell: bash + run: | + set -euo pipefail + install -d -m 0700 "$(dirname "${RELEASE_BUNDLE}")" + python ops/gcp_leoclean_nosend_package.py finalize \ + --image-input "${IMAGE_CONTEXT}/image-input.json" \ + --build-push-receipt "${BUILD_PUSH_RECEIPT}" \ + --docker-binary /usr/bin/docker \ + --docker-host unix:///var/run/docker.sock \ + --docker-config "${DOCKER_CONFIG}" \ + --output-bundle "${RELEASE_BUNDLE}" + python ops/gcp_leoclean_nosend_package.py validate \ + --release "${RELEASE_BUNDLE}/release.json" + + python - "${BUILD_PUSH_RECEIPT}" "${RELEASE_BUNDLE}/release.json" "${OCI_SMOKE_RECEIPT}" "${GITHUB_SHA}" <<'PY' + import json + import sys + from pathlib import Path + + receipt = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + release = json.loads(Path(sys.argv[2]).read_text(encoding="utf-8")) + smoke = json.loads(Path(sys.argv[3]).read_text(encoding="utf-8")) + revision = sys.argv[4] + assert receipt["schema"] == "livingip.leocleanNoSendBuildPushReceipt.v1" + assert release["schema"] == "livingip.leocleanNoSendRelease.v3" + assert release["build_push_receipt"] == receipt + assert release["image_input"]["runtime"]["teleo_git_head"] == revision + assert release["image_input"]["input_sha256"] == smoke["input_sha256"] + assert release["image"]["reference"].startswith( + "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging@sha256:" + ) + PY + + - name: Upload exact build and release evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: leoclean-nosend-release-${{ github.sha }} + path: | + ${{ env.RUNTIME_RECEIPT }} + ${{ env.OCI_SMOKE_RECEIPT }} + ${{ env.BUILD_PUSH_RECEIPT }} + ${{ env.RELEASE_BUNDLE }}/release.json + ${{ env.RELEASE_BUNDLE }}/leoclean-gcp-nosend.service + if-no-files-found: error + retention-days: 30 + + - name: Remove ephemeral Docker authentication + if: always() + shell: bash + run: rm -rf -- "${DOCKER_CONFIG}" diff --git a/tests/test_gcp_leoclean_nosend_release_workflow.py b/tests/test_gcp_leoclean_nosend_release_workflow.py new file mode 100644 index 0000000..2bce3ca --- /dev/null +++ b/tests/test_gcp_leoclean_nosend_release_workflow.py @@ -0,0 +1,146 @@ +from pathlib import Path + +import yaml + +from ops import gcp_leoclean_nosend_package as package + +WORKFLOW = Path(".github/workflows/gcp-leoclean-nosend-release.yml") + + +def load_workflow() -> dict[str, object]: + return yaml.load(WORKFLOW.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) + + +def workflow_step(name: str) -> dict[str, object]: + workflow = load_workflow() + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + publish = jobs["publish"] + assert isinstance(publish, dict) + steps = publish["steps"] + assert isinstance(steps, list) + return next(step for step in steps if step.get("name") == name) + + +def test_release_workflow_is_manual_main_only_and_serialized() -> None: + workflow = load_workflow() + + assert set(workflow["on"]) == {"workflow_dispatch"} + assert workflow["permissions"] == {"contents": "read", "id-token": "write"} + assert workflow["concurrency"] == { + "group": "gcp-leoclean-nosend-release-main", + "cancel-in-progress": "false", + } + guard = workflow_step("Require manual main dispatch")["run"] + assert 'test "${GITHUB_EVENT_NAME}" = "workflow_dispatch"' in guard + assert 'test "${GITHUB_REF}" = "refs/heads/main"' in guard + assert 'test "${GITHUB_REF_TYPE}" = "branch"' in guard + + +def test_release_workflow_checks_out_exact_clean_revision_without_persisted_token() -> None: + step = workflow_step("Check out exact dispatched revision") + + assert step["uses"] == "actions/checkout@11d5960a326750d5838078e36cf38b85af677262" + assert step["with"] == { + "fetch-depth": "0", + "persist-credentials": "false", + "ref": "${{ github.sha }}", + } + revision_check = workflow_step("Verify exact clean main revision")["run"] + assert 'test "$(git rev-parse --verify HEAD)" = "${GITHUB_SHA}"' in revision_check + assert 'test "$(git rev-parse --verify refs/remotes/origin/main^{commit})" = "${GITHUB_SHA}"' in revision_check + assert "git status --porcelain=v1 --untracked-files=all" in revision_check + + +def test_release_workflow_smokes_before_requesting_cloud_identity() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + + smoke = workflow.index("Smoke exact no-send OCI surface") + auth = workflow.index("Authenticate to Google Cloud through WIF") + push = workflow.index("Build and push one immutable candidate") + finalize = workflow.index("Finalize and validate release v3 bundle") + upload = workflow.index("Upload exact build and release evidence") + assert smoke < auth < push < finalize < upload + assert "scripts/run_leoclean_nosend_runtime_canary.sh" in workflow + assert "scripts/run_gcp_leoclean_nosend_oci_smoke.py" in workflow + assert 'assert smoke["input_sha256"] == image_input["input_sha256"]' in workflow + assert 'assert smoke["cleanup"]["status"] == "pass"' in workflow + + +def test_release_workflow_uses_exact_wif_artifact_builder_without_repository_secrets() -> None: + workflow = load_workflow() + text = WORKFLOW.read_text(encoding="utf-8") + env = workflow["env"] + + assert env["PROJECT_ID"] == "teleo-501523" + assert env["REGION"] == "europe-west6" + assert env["ARTIFACT_REPOSITORY"] == "teleo" + assert env["IMAGE_REPOSITORY"] == package.IMAGE_REPOSITORY + assert env["WORKLOAD_IDENTITY_PROVIDER"] == ( + "projects/785938879453/locations/global/workloadIdentityPools/github-actions/providers/living-ip-github" + ) + assert env["ARTIFACT_SERVICE_ACCOUNT"] == "sa-artifact-builder@teleo-501523.iam.gserviceaccount.com" + assert "${{ secrets." not in text + assert "password" not in text.casefold() + + auth = workflow_step("Authenticate to Google Cloud through WIF") + assert auth["uses"] == "google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed" + assert auth["with"] == { + "workload_identity_provider": "${{ env.WORKLOAD_IDENTITY_PROVIDER }}", + "service_account": "${{ env.ARTIFACT_SERVICE_ACCOUNT }}", + "create_credentials_file": "true", + "export_environment_variables": "true", + } + + +def test_release_workflow_requires_immutable_repository_and_package_owned_candidate_push() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + immutable = workflow_step("Require immutable fixed Artifact Registry repository")["run"] + push = workflow_step("Build and push one immutable candidate")["run"] + + assert "gcloud artifacts repositories describe" in immutable + assert 'repository["dockerConfig"]["immutableTags"] is True' in immutable + assert "ops/gcp_leoclean_nosend_package.py build-push" in push + assert "--execute-staging-push" in push + assert "--docker-binary /usr/bin/docker" in push + assert "--docker-host unix:///var/run/docker.sock" in push + assert "docker push" not in workflow + assert "docker tag" not in workflow + assert "candidate-" not in workflow + + +def test_release_workflow_finalizes_v3_and_uploads_only_allowlisted_evidence() -> None: + finalize = workflow_step("Finalize and validate release v3 bundle")["run"] + upload = workflow_step("Upload exact build and release evidence") + paths = upload["with"]["path"].splitlines() + + assert "ops/gcp_leoclean_nosend_package.py finalize" in finalize + assert "ops/gcp_leoclean_nosend_package.py validate" in finalize + assert 'assert release["schema"] == "livingip.leocleanNoSendRelease.v3"' in finalize + assert paths == [ + "${{ env.RUNTIME_RECEIPT }}", + "${{ env.OCI_SMOKE_RECEIPT }}", + "${{ env.BUILD_PUSH_RECEIPT }}", + "${{ env.RELEASE_BUNDLE }}/release.json", + "${{ env.RELEASE_BUNDLE }}/leoclean-gcp-nosend.service", + ] + assert upload["with"]["if-no-files-found"] == "error" + assert upload["with"]["retention-days"] == "30" + assert "*" not in upload["with"]["path"] + assert "google-github-actions" not in upload["with"]["path"] + + +def test_release_workflow_has_no_deployment_or_runtime_mutation_surface() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8").casefold() + forbidden = ( + "--execute-restart", + "install_gcp_leoclean_nosend_release.py", + "gcloud compute", + "gcloud sql", + "gcloud secrets", + "kubectl", + "systemctl", + "terraform apply", + ) + + assert all(marker not in workflow for marker in forbidden) From 7aabfa4ab8ca412f9ca38e8900ff555201cc82ff Mon Sep 17 00:00:00 2001 From: twentyOne2x Date: Wed, 22 Jul 2026 05:44:23 +0200 Subject: [PATCH 2/2] fix: make no-send release publication repeatable --- .../workflows/gcp-leoclean-nosend-release.yml | 91 +- ops/gcp_leoclean_nosend_package.py | 971 ++++++++++++++++-- tests/test_gcp_leoclean_nosend_package.py | 409 +++++++- ...st_gcp_leoclean_nosend_release_workflow.py | 54 +- 4 files changed, 1408 insertions(+), 117 deletions(-) diff --git a/.github/workflows/gcp-leoclean-nosend-release.yml b/.github/workflows/gcp-leoclean-nosend-release.yml index a953203..9d84c0c 100644 --- a/.github/workflows/gcp-leoclean-nosend-release.yml +++ b/.github/workflows/gcp-leoclean-nosend-release.yml @@ -24,15 +24,6 @@ jobs: name: Build, smoke, and publish exact no-send release runs-on: ubuntu-latest timeout-minutes: 90 - env: - RELEASE_ROOT: ${{ runner.temp }}/leoclean-nosend-release - RUNTIME_ARTIFACT: ${{ runner.temp }}/leoclean-nosend-release/runtime-artifact - RUNTIME_RECEIPT: ${{ runner.temp }}/leoclean-nosend-release/evidence/runtime-receipt.json - OCI_SMOKE_RECEIPT: ${{ runner.temp }}/leoclean-nosend-release/evidence/oci-smoke.json - IMAGE_CONTEXT: ${{ runner.temp }}/leoclean-nosend-release/image-context - BUILD_PUSH_RECEIPT: ${{ runner.temp }}/leoclean-nosend-release/receipts/build-push-receipt.json - RELEASE_BUNDLE: ${{ runner.temp }}/leoclean-nosend-release/final/release-v3 - DOCKER_CONFIG: ${{ runner.temp }}/leoclean-nosend-release/docker-config steps: - name: Require manual main dispatch shell: bash @@ -41,6 +32,9 @@ jobs: test "${GITHUB_EVENT_NAME}" = "workflow_dispatch" test "${GITHUB_REF}" = "refs/heads/main" test "${GITHUB_REF_TYPE}" = "branch" + test "${GITHUB_REPOSITORY}" = "living-ip/teleo-infrastructure" + test "${GITHUB_WORKFLOW_REF}" = \ + "living-ip/teleo-infrastructure/.github/workflows/gcp-leoclean-nosend-release.yml@refs/heads/main" - name: Check out exact dispatched revision uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 @@ -57,6 +51,24 @@ jobs: test "$(git rev-parse --verify refs/remotes/origin/main^{commit})" = "${GITHUB_SHA}" test -z "$(git status --porcelain=v1 --untracked-files=all)" + - name: Initialize private release paths + shell: bash + run: | + set -euo pipefail + release_root="${RUNNER_TEMP}/leoclean-nosend-release" + install -d -m 0700 "${release_root}" + { + echo "RELEASE_ROOT=${release_root}" + echo "RUNTIME_ARTIFACT=${release_root}/runtime-artifact" + echo "RUNTIME_RECEIPT=${release_root}/evidence/runtime-receipt.json" + echo "OCI_SMOKE_RECEIPT=${release_root}/evidence/oci-smoke.json" + echo "IMAGE_CONTEXT=${release_root}/image-context" + echo "BUILD_PUSH_RECEIPT=${release_root}/receipts/build-push-receipt.json" + echo "PUBLISH_OUTCOME=${release_root}/evidence/publish-outcome.json" + echo "RELEASE_BUNDLE=${release_root}/final/release-v3" + echo "DOCKER_CONFIG=${release_root}/docker-config" + } >>"${GITHUB_ENV}" + - name: Set up exact Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 with: @@ -169,6 +181,16 @@ jobs: assert smoke["identity_sha256"] == image_input["identity"]["bundle_sha256"] PY + - name: Initialize pre-mutation publish outcome + id: init_outcome + shell: bash + run: | + set -euo pipefail + install -d -m 0700 "$(dirname "${PUBLISH_OUTCOME}")" + python ops/gcp_leoclean_nosend_package.py init-outcome \ + --image-input "${IMAGE_CONTEXT}/image-input.json" \ + --output-journal "${PUBLISH_OUTCOME}" + - name: Authenticate to Google Cloud through WIF id: auth uses: google-github-actions/auth@c200f3691d83b41bf9bbd8638997a462592937ed @@ -189,6 +211,8 @@ jobs: set -euo pipefail repository_readback="${RUNNER_TEMP}/leoclean-nosend-repository.json" umask 077 + test "$(gcloud auth list --filter=status:ACTIVE --format='value(account)')" = \ + "${ARTIFACT_SERVICE_ACCOUNT}" gcloud artifacts repositories describe "${ARTIFACT_REPOSITORY}" \ --project "${PROJECT_ID}" \ --location "${REGION}" \ @@ -218,6 +242,7 @@ jobs: chmod 0600 "${DOCKER_CONFIG}/config.json" - name: Build and push one immutable candidate + id: build_push shell: bash run: | set -euo pipefail @@ -228,10 +253,13 @@ jobs: --docker-binary /usr/bin/docker \ --docker-host unix:///var/run/docker.sock \ --docker-config "${DOCKER_CONFIG}" \ + --gcloud-binary "$(command -v gcloud)" \ --output-receipt "${BUILD_PUSH_RECEIPT}" \ + --outcome-journal "${PUBLISH_OUTCOME}" \ --execute-staging-push - name: Finalize and validate release v3 bundle + id: finalize shell: bash run: | set -euo pipefail @@ -265,20 +293,41 @@ jobs: ) PY - - name: Upload exact build and release evidence + - name: Remove ephemeral Docker authentication + id: cleanup + if: always() + shell: bash + run: | + set -euo pipefail + rm -rf -- "${DOCKER_CONFIG}" + test ! -e "${DOCKER_CONFIG}" + test ! -L "${DOCKER_CONFIG}" + + - name: Finalize publish outcome + if: always() && steps.init_outcome.outcome == 'success' + shell: bash + run: | + set -euo pipefail + python ops/gcp_leoclean_nosend_package.py complete-outcome \ + --image-input "${IMAGE_CONTEXT}/image-input.json" \ + --outcome-journal "${PUBLISH_OUTCOME}" \ + --build-push-receipt "${BUILD_PUSH_RECEIPT}" \ + --release "${RELEASE_BUNDLE}/release.json" \ + --build-outcome "${{ steps.build_push.outcome }}" \ + --finalize-outcome "${{ steps.finalize.outcome }}" \ + --cleanup-outcome "${{ steps.cleanup.outcome }}" + + - name: Upload exact available build and release evidence + if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: leoclean-nosend-release-${{ github.sha }} path: | - ${{ env.RUNTIME_RECEIPT }} - ${{ env.OCI_SMOKE_RECEIPT }} - ${{ env.BUILD_PUSH_RECEIPT }} - ${{ env.RELEASE_BUNDLE }}/release.json - ${{ env.RELEASE_BUNDLE }}/leoclean-gcp-nosend.service - if-no-files-found: error + ${{ runner.temp }}/leoclean-nosend-release/evidence/runtime-receipt.json + ${{ runner.temp }}/leoclean-nosend-release/evidence/oci-smoke.json + ${{ runner.temp }}/leoclean-nosend-release/evidence/publish-outcome.json + ${{ runner.temp }}/leoclean-nosend-release/receipts/build-push-receipt.json + ${{ runner.temp }}/leoclean-nosend-release/final/release-v3/release.json + ${{ runner.temp }}/leoclean-nosend-release/final/release-v3/leoclean-gcp-nosend.service + if-no-files-found: warn retention-days: 30 - - - name: Remove ephemeral Docker authentication - if: always() - shell: bash - run: rm -rf -- "${DOCKER_CONFIG}" diff --git a/ops/gcp_leoclean_nosend_package.py b/ops/gcp_leoclean_nosend_package.py index 83bce46..bfe2e48 100644 --- a/ops/gcp_leoclean_nosend_package.py +++ b/ops/gcp_leoclean_nosend_package.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import copy import ctypes import errno import fcntl @@ -15,6 +16,7 @@ import shutil import stat import subprocess import sys +import time import tomllib import uuid from collections.abc import Mapping @@ -34,6 +36,7 @@ from scripts import leo_identity_manifest as strict_identity_manifest # noqa: E IMAGE_INPUT_SCHEMA = "livingip.leocleanNoSendImageInput.v2" IMAGE_INSPECTION_SCHEMA = "livingip.leocleanNoSendImageInspection.v1" BUILD_PUSH_RECEIPT_SCHEMA = "livingip.leocleanNoSendBuildPushReceipt.v1" +PUBLISH_OUTCOME_SCHEMA = "livingip.leocleanNoSendPublishOutcome.v1" RELEASE_SCHEMA = "livingip.leocleanNoSendRelease.v3" ARTIFACT_MANIFEST_SCHEMA = "livingip.leocleanNoSendArtifactManifest.v1" ARTIFACT_RECEIPT_SCHEMA = "livingip.leocleanNoSendArtifactVerification.v1" @@ -50,6 +53,9 @@ SERVICE = "leoclean-gcp-nosend.service" SERVICE_ACCOUNT = "sa-teleo-staging-vm@teleo-501523.iam.gserviceaccount.com" PLATFORM = "linux/amd64" IMAGE_REPOSITORY = "europe-west6-docker.pkg.dev/teleo-501523/teleo/leoclean-nosend-staging" +ARTIFACT_PACKAGE_RESOURCE = ( + "projects/teleo-501523/locations/europe-west6/repositories/teleo/packages/leoclean-nosend-staging" +) CONTAINER_NAME = "livingip-leoclean-nosend" LOCAL_BUILD_REPOSITORY = "livingip-leoclean-nosend-build" DOCKER_BINARY = "/usr/bin/docker" @@ -83,7 +89,9 @@ RUNTIME_UID = 65532 RUNTIME_GID = 65532 MAX_DOCKER_INSPECTION_BYTES = 1024 * 1024 MAX_DOCKER_COMMAND_OUTPUT_BYTES = 1024 * 1024 +MAX_GCLOUD_COMMAND_OUTPUT_BYTES = 1024 * 1024 MAX_BUILD_PUSH_RECEIPT_BYTES = 256 * 1024 +MAX_PUBLISH_OUTCOME_BYTES = 256 * 1024 IMAGE_RUNTIME_CONFIG = { "user": None, "environment": [ @@ -138,6 +146,8 @@ IMAGE_RUNTIME_CONFIG = { HEX_40 = re.compile(r"^[0-9a-f]{40}$") HEX_64 = re.compile(r"^[0-9a-f]{64}$") IMAGE_REFERENCE = re.compile(rf"^{re.escape(IMAGE_REPOSITORY)}@sha256:([0-9a-f]{{64}})$") +CANDIDATE_TAG = re.compile(r"^candidate-([0-9a-f]{64})$") +CANDIDATE_REFERENCE = re.compile(rf"^{re.escape(IMAGE_REPOSITORY)}:candidate-([0-9a-f]{{64}})$") FORBIDDEN_DESCRIPTOR_MARKERS = ( "77.42.65.182", "gcp-teleo-pgvector-standby-postgres-password", @@ -1821,6 +1831,244 @@ def build_build_push_receipt( return receipt +def _seal_publish_outcome(stable: dict[str, Any]) -> dict[str, Any]: + value = {**stable, "outcome_sha256": canonical_sha256(stable)} + validate_publish_outcome(value) + return value + + +def validate_publish_outcome(value: dict[str, Any], *, image_input: dict[str, Any] | None = None) -> None: + _require( + set(value) + == { + "schema", + "status", + "source_revision", + "input_sha256", + "candidate", + "registry", + "phase", + "failure_phase", + "cleanup", + "release", + "events", + "outcome_sha256", + }, + "publish outcome fields are not exact", + ) + _require(value.get("schema") == PUBLISH_OUTCOME_SCHEMA, "publish outcome schema is invalid") + _require(value.get("status") in {"in_progress", "pass", "fail"}, "publish outcome status is invalid") + _require(_is_revision(value.get("source_revision")), "publish outcome source revision is invalid") + _require(_is_sha256(value.get("input_sha256")), "publish outcome input hash is invalid") + if image_input is not None: + validate_image_input(image_input) + _require( + value["source_revision"] == image_input["runtime"]["teleo_git_head"] + and value["input_sha256"] == image_input["input_sha256"], + "publish outcome image-input binding drifted", + ) + + candidate = value.get("candidate") + _require( + isinstance(candidate, dict) and set(candidate) == {"tag", "reference", "local_build_config_digest"}, + "publish outcome candidate fields are not exact", + ) + candidate_values = ( + candidate.get("tag"), + candidate.get("reference"), + candidate.get("local_build_config_digest"), + ) + if candidate_values == (None, None, None): + pass + else: + tag, reference, local_build_config_digest = candidate_values + _require( + isinstance(tag, str) + and CANDIDATE_TAG.fullmatch(tag) is not None + and isinstance(reference, str) + and CANDIDATE_REFERENCE.fullmatch(reference) is not None + and isinstance(local_build_config_digest, str) + and local_build_config_digest.startswith("sha256:") + and _is_sha256(local_build_config_digest.removeprefix("sha256:")), + "publish outcome candidate is invalid", + ) + _require( + tag == f"candidate-{value['input_sha256']}" and reference == f"{IMAGE_REPOSITORY}:{tag}", + "candidate binding drifted", + ) + + registry = value.get("registry") + _require( + isinstance(registry, dict) + and set(registry) == {"repository", "preexisting", "push_attempted", "action", "observed_digest"}, + "publish outcome registry fields are not exact", + ) + _require(registry.get("repository") == IMAGE_REPOSITORY, "publish outcome repository is not exact") + _require(registry.get("preexisting") in {None, True, False}, "publish outcome preexisting state is invalid") + _require(type(registry.get("push_attempted")) is bool, "publish outcome push-attempt state is invalid") + _require( + registry.get("action") + in {"none", "observed", "fresh_push", "reused", "indeterminate", "reconciled_after_attempt"}, + "publish outcome registry action is invalid", + ) + observed_digest = registry.get("observed_digest") + _require( + observed_digest is None + or ( + isinstance(observed_digest, str) + and observed_digest.startswith("sha256:") + and _is_sha256(observed_digest.removeprefix("sha256:")) + ), + "publish outcome observed digest is invalid", + ) + candidate_is_set = candidate_values != (None, None, None) + registry_state = ( + registry["preexisting"], + registry["push_attempted"], + registry["action"], + observed_digest is not None, + ) + _require( + registry_state + in { + (None, False, "none", False), + (False, False, "none", False), + (True, False, "observed", True), + (True, False, "reused", True), + (False, True, "indeterminate", False), + (False, True, "indeterminate", True), + (False, True, "fresh_push", True), + (False, True, "reconciled_after_attempt", True), + }, + "publish outcome registry state is inconsistent", + ) + _require( + candidate_is_set or registry_state == (None, False, "none", False), + "publish outcome registry activity has no candidate", + ) + + phase = value.get("phase") + failure_phase = value.get("failure_phase") + _require(isinstance(phase, str) and bool(re.fullmatch(r"[a-z][a-z0-9_]{0,63}", phase)), "publish phase is invalid") + _require( + failure_phase is None + or (isinstance(failure_phase, str) and bool(re.fullmatch(r"[a-z][a-z0-9_]{0,63}", failure_phase))), + "publish failure phase is invalid", + ) + _require( + (value["status"] == "fail") == (failure_phase is not None), + "publish failure status and phase disagree", + ) + + cleanup = value.get("cleanup") + _require( + isinstance(cleanup, dict) + and cleanup + == { + "docker_config": cleanup.get("docker_config"), + "local_daemon": "runner_ephemeral_references_retained", + "remote_candidate": "immutable_evidence_retained", + } + and cleanup.get("docker_config") in {"pending", "pass", "fail"}, + "publish cleanup state is invalid", + ) + release = value.get("release") + _require( + isinstance(release, dict) + and set(release) == {"build_push_receipt_present", "bundle_present", "release_sha256"} + and type(release.get("build_push_receipt_present")) is bool + and type(release.get("bundle_present")) is bool, + "publish release state is invalid", + ) + release_sha256 = release.get("release_sha256") + _require(release_sha256 is None or _is_sha256(release_sha256), "publish release hash is invalid") + if value["status"] == "pass": + _require( + registry["action"] in {"fresh_push", "reused", "reconciled_after_attempt"} + and observed_digest is not None + and cleanup["docker_config"] == "pass" + and release["build_push_receipt_present"] is True + and release["bundle_present"] is True + and _is_sha256(release_sha256), + "passing publish outcome is incomplete", + ) + + events = value.get("events") + _require(isinstance(events, list) and bool(events), "publish outcome events are missing") + for event in events: + _require( + isinstance(event, dict) + and set(event) == {"phase", "status"} + and isinstance(event.get("phase"), str) + and bool(re.fullmatch(r"[a-z][a-z0-9_]{0,63}", event["phase"])) + and event.get("status") + in {"initialized", "attempted", "absent", "present", "pass", "fail", "recovered", "reused", "skipped"}, + "publish outcome event is invalid", + ) + stable = {key: item for key, item in value.items() if key != "outcome_sha256"} + _require(value.get("outcome_sha256") == canonical_sha256(stable), "publish outcome self-hash drifted") + _assert_no_forbidden_descriptor_markers(value) + + +def build_initial_publish_outcome(image_input: dict[str, Any]) -> dict[str, Any]: + validate_image_input(image_input) + stable = { + "schema": PUBLISH_OUTCOME_SCHEMA, + "status": "in_progress", + "source_revision": image_input["runtime"]["teleo_git_head"], + "input_sha256": image_input["input_sha256"], + "candidate": {"tag": None, "reference": None, "local_build_config_digest": None}, + "registry": { + "repository": IMAGE_REPOSITORY, + "preexisting": None, + "push_attempted": False, + "action": "none", + "observed_digest": None, + }, + "phase": "initialized", + "failure_phase": None, + "cleanup": { + "docker_config": "pending", + "local_daemon": "runner_ephemeral_references_retained", + "remote_candidate": "immutable_evidence_retained", + }, + "release": { + "build_push_receipt_present": False, + "bundle_present": False, + "release_sha256": None, + }, + "events": [{"phase": "initialized", "status": "initialized"}], + } + return _seal_publish_outcome(stable) + + +def advance_publish_outcome( + value: dict[str, Any], + *, + phase: str, + event_status: str, + status: str | None = None, + failure_phase: str | None = None, + candidate: dict[str, Any] | None = None, + registry: dict[str, Any] | None = None, + cleanup: dict[str, Any] | None = None, + release: dict[str, Any] | None = None, +) -> dict[str, Any]: + validate_publish_outcome(value) + _require(value["status"] != "pass", "completed publish outcome cannot advance") + stable = copy.deepcopy({key: item for key, item in value.items() if key != "outcome_sha256"}) + stable["phase"] = phase + stable["events"].append({"phase": phase, "status": event_status}) + if status is not None: + stable["status"] = status + if failure_phase is not None: + stable["failure_phase"] = failure_phase + for key, updates in (("candidate", candidate), ("registry", registry), ("cleanup", cleanup), ("release", release)): + if updates is not None: + stable[key].update(updates) + return _seal_publish_outcome(stable) + + def build_image_inspection( image_input: dict[str, Any], image_reference: str, @@ -2133,6 +2381,197 @@ def _bounded_docker_output(result: subprocess.CompletedProcess[bytes], label: st ) +def _validate_gcloud_binary(gcloud_binary: Path) -> None: + _require(gcloud_binary.is_absolute() and "\x00" not in str(gcloud_binary), "gcloud binary must be absolute") + try: + observed = gcloud_binary.stat(follow_symlinks=False) + except OSError as exc: + raise PackageError("gcloud binary is unavailable") from exc + _require( + stat.S_ISREG(observed.st_mode) + and observed.st_uid in {0, os.geteuid()} + and bool(observed.st_mode & 0o111) + and observed.st_mode & 0o022 == 0, + "gcloud binary posture is unsafe", + ) + + +def _run_gcloud(gcloud_binary: Path, arguments: list[str], *, timeout: int = 60) -> subprocess.CompletedProcess[bytes]: + _validate_gcloud_binary(gcloud_binary) + environment = os.environ.copy() + environment["CLOUDSDK_CORE_DISABLE_PROMPTS"] = "1" + try: + result = subprocess.run( + [str(gcloud_binary), *arguments], + check=False, + capture_output=True, + stdin=subprocess.DEVNULL, + timeout=timeout, + env=environment, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise PackageError("registry tag lookup failed") from exc + _require( + len(result.stdout) <= MAX_GCLOUD_COMMAND_OUTPUT_BYTES and len(result.stderr) <= MAX_GCLOUD_COMMAND_OUTPUT_BYTES, + "registry tag lookup output is too large", + ) + _require(result.returncode == 0, "registry tag lookup failed") + return result + + +def _registry_candidate_digest(gcloud_binary: Path, candidate_tag: str) -> str | None: + _require(CANDIDATE_TAG.fullmatch(candidate_tag) is not None, "registry candidate tag is invalid") + expected_tag = f"{ARTIFACT_PACKAGE_RESOURCE}/tags/{candidate_tag}" + expected_version_prefix = f"{ARTIFACT_PACKAGE_RESOURCE}/versions/sha256:" + result = _run_gcloud( + gcloud_binary, + [ + "artifacts", + "docker", + "tags", + "list", + IMAGE_REPOSITORY, + "--project", + PROJECT, + f"--filter=tag={expected_tag}", + "--format=json(tag,version)", + "--limit=2", + ], + ) + try: + rows = _strict_json_loads(result.stdout.decode("utf-8"), "registry candidate lookup") + except UnicodeError as exc: + raise PackageError("registry candidate lookup is invalid") from exc + _require(isinstance(rows, list), "registry candidate lookup is invalid") + if not rows: + return None + _require( + len(rows) == 1 + and isinstance(rows[0], dict) + and set(rows[0]) == {"tag", "version"} + and rows[0].get("tag") == expected_tag, + "registry candidate lookup returned an unexpected tag", + ) + row = rows[0] + version = row.get("version") + digest = version.removeprefix(expected_version_prefix) if isinstance(version, str) else "" + _require( + version == f"{expected_version_prefix}{digest}" and _is_sha256(digest), "registry candidate version is invalid" + ) + return f"sha256:{digest}" + + +def _wait_registry_candidate_digest( + gcloud_binary: Path, + candidate_tag: str, + *, + attempts: int = 6, + interval_seconds: float = 2.0, +) -> str | None: + _require(type(attempts) is int and attempts > 0, "registry lookup attempts are invalid") + for attempt in range(attempts): + digest = _registry_candidate_digest(gcloud_binary, candidate_tag) + if digest is not None: + return digest + if attempt + 1 < attempts: + time.sleep(interval_seconds) + return None + + +def _pull_validated_registry_candidate( + image_input: dict[str, Any], + expected_local: dict[str, Any], + manifest_digest: str, + *, + docker_binary: Path, + docker_host: str, + docker_config: Path, + authority: _DockerCacheLock, +) -> dict[str, Any]: + _require( + isinstance(manifest_digest, str) + and manifest_digest.startswith("sha256:") + and _is_sha256(manifest_digest.removeprefix("sha256:")), + "registry candidate digest is invalid", + ) + reference = f"{IMAGE_REPOSITORY}@{manifest_digest}" + pulled = _run_docker( + docker_binary, + docker_host, + docker_config, + ["pull", "--quiet", "--platform", PLATFORM, reference], + check=True, + timeout=600, + authority=authority, + ) + _bounded_docker_output(pulled, "registry candidate pull") + remote_image = _inspect_local_image( + docker_binary, + docker_host, + docker_config, + reference, + authority=authority, + ) + remote_configuration = validate_image_configuration(image_input, remote_image) + for field in ("platform", "labels", "runtime_config"): + _require( + remote_configuration[field] == expected_local[field], + "existing registry candidate runtime differs from the exact input", + ) + _require( + _target_repository_digests(remote_image) == [reference], + "existing registry candidate digest binding is not exact", + ) + verified = _run_docker( + docker_binary, + docker_host, + docker_config, + [ + "run", + "--rm", + "--pull=never", + "--platform", + PLATFORM, + "--network=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--pids-limit=64", + f"--user={RUNTIME_UID}:{RUNTIME_GID}", + reference, + "verify-build", + image_input["runtime"]["teleo_git_head"], + image_input["runtime"]["artifact_sha256"], + image_input["identity"]["bundle_sha256"], + image_input["input_sha256"], + ], + check=True, + timeout=120, + authority=authority, + ) + _bounded_docker_output(verified, "registry candidate runtime verification") + try: + verification = _strict_json_loads(verified.stdout.decode("utf-8"), "registry candidate verification") + except UnicodeError as exc: + raise PackageError("registry candidate verification is invalid") from exc + _require( + verification + == { + "schema": "livingip.leocleanNoSendContainerVerification.v1", + "status": "pass", + "teleo_git_head": image_input["runtime"]["teleo_git_head"], + "artifact_sha256": image_input["runtime"]["artifact_sha256"], + "identity_sha256": image_input["identity"]["bundle_sha256"], + "input_sha256": image_input["input_sha256"], + "python_version": PYTHON_VERSION, + "postgres_version": POSTGRES_VERSION, + "cloudsql_ca_sha256": CA_SHA256, + }, + "registry candidate verification differs from the exact input", + ) + return remote_image + + def _inspect_local_image( docker_binary: Path, docker_host: str, @@ -2282,22 +2721,56 @@ def build_and_push_staging_image( docker_binary: Path, docker_host: str, docker_config: Path, + gcloud_binary: Path, output_receipt: Path, + outcome_journal: Path, execute_staging_push: bool, ) -> dict[str, Any]: - """Build one reviewed context, push one content-bound candidate, and receipt the result.""" + """Build and reconcile one content-bound candidate, then receipt the exact digest.""" _require(execute_staging_push is True, "staging image push requires explicit execution authorization") image_input, build_args = validate_prepared_image_context(context) - validate_reviewed_context_source(repo_root, context, image_input) - _validate_docker_authority(docker_binary, docker_host, docker_config) - _validate_receipt_output_path(output_receipt) - _validate_receipt_context_separation(context, output_receipt) + outcome = load_publish_outcome(outcome_journal, image_input=image_input) + _require(outcome["status"] == "in_progress", "publish outcome is not active") + current_phase = "preflight" + push_attempted = False + observed_digest: str | None = None + + def record( + phase: str, + event_status: str, + *, + status: str | None = None, + failure_phase: str | None = None, + candidate: dict[str, Any] | None = None, + registry: dict[str, Any] | None = None, + release: dict[str, Any] | None = None, + ) -> None: + nonlocal outcome, current_phase + current_phase = phase + outcome = advance_publish_outcome( + outcome, + phase=phase, + event_status=event_status, + status=status, + failure_phase=failure_phase, + candidate=candidate, + registry=registry, + release=release, + ) + publish_publish_outcome(outcome_journal, outcome, create=False) build_tag = f"input-{image_input['input_sha256']}" build_reference = f"{LOCAL_BUILD_REPOSITORY}:{build_tag}" + validate_reviewed_context_source(repo_root, context, image_input) + _validate_docker_authority(docker_binary, docker_host, docker_config) + _validate_gcloud_binary(gcloud_binary) + _validate_receipt_output_path(output_receipt) + _validate_receipt_context_separation(context, output_receipt) + record("preflight", "pass") try: with _DockerCacheLock(docker_config) as authority: + current_phase = "local_build" _require( _local_tag_config_id( docker_binary, @@ -2366,92 +2839,163 @@ def build_and_push_staging_image( ) validate_reviewed_context_source(repo_root, context, post_build_input) - candidate_tag = f"candidate-{build_config_id.removeprefix('sha256:')}" + candidate_tag = f"candidate-{image_input['input_sha256']}" candidate_reference = f"{IMAGE_REPOSITORY}:{candidate_tag}" - _require( - _local_tag_config_id( + record( + "candidate_derived", + "pass", + candidate={ + "tag": candidate_tag, + "reference": candidate_reference, + "local_build_config_digest": build_config_id, + }, + ) + + current_phase = "remote_reconcile" + existing_digest = _registry_candidate_digest(gcloud_binary, candidate_tag) + if existing_digest is not None: + observed_digest = existing_digest + record( + "remote_reconcile", + "present", + registry={ + "preexisting": True, + "action": "observed", + "observed_digest": existing_digest, + }, + ) + remote_image = _pull_validated_registry_candidate( + image_input, + observed, + existing_digest, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + authority=authority, + ) + receipt = build_build_push_receipt(image_input, post_push_image=remote_image) + record("remote_readback", "reused", registry={"action": "reused"}) + else: + record("remote_reconcile", "absent", registry={"preexisting": False}) + _require( + _local_tag_config_id( + docker_binary, + docker_host, + docker_config, + repository=IMAGE_REPOSITORY, + tag=candidate_tag, + authority=authority, + ) + is None, + "content-bound staging candidate tag already exists locally", + ) + tagged = _run_docker( docker_binary, docker_host, docker_config, - repository=IMAGE_REPOSITORY, - tag=candidate_tag, + ["image", "tag", build_reference, candidate_reference], + check=True, + timeout=60, authority=authority, ) - is None, - "content-bound staging candidate tag already exists locally", - ) - tagged = _run_docker( - docker_binary, - docker_host, - docker_config, - ["image", "tag", build_reference, candidate_reference], - check=True, - timeout=60, - authority=authority, - ) - _bounded_docker_output(tagged, "staging candidate tag") - _require( - _local_tag_config_id( + _bounded_docker_output(tagged, "staging candidate tag") + _require( + _local_tag_config_id( + docker_binary, + docker_host, + docker_config, + repository=IMAGE_REPOSITORY, + tag=candidate_tag, + authority=authority, + ) + == build_config_id, + "staging candidate tag differs from the built image", + ) + pre_push_image = _inspect_local_image( docker_binary, docker_host, docker_config, - repository=IMAGE_REPOSITORY, - tag=candidate_tag, + candidate_reference, authority=authority, ) - == build_config_id, - "staging candidate tag differs from the built image", - ) - pre_push_image = _inspect_local_image( - docker_binary, - docker_host, - docker_config, - candidate_reference, - authority=authority, - ) - _require( - validate_image_configuration(image_input, pre_push_image) == observed, - "staging candidate configuration drifted before push", - ) - _require( - _target_repository_digests(pre_push_image) == [], - "staging candidate already has a repository digest before push", - ) - pushed = _run_docker( - docker_binary, - docker_host, - docker_config, - ["image", "push", "--quiet", candidate_reference], - check=True, - timeout=1800, - authority=authority, - ) - _bounded_docker_output(pushed, "staging image push") - post_push_image = _inspect_local_image( - docker_binary, - docker_host, - docker_config, - candidate_reference, - authority=authority, - ) - _require( - validate_image_configuration(image_input, post_push_image) == observed, - "staging candidate configuration drifted across push", - ) - repository_digests = _target_repository_digests(post_push_image) - _require(len(repository_digests) == 1, "staging push did not produce one exact repository digest") - registry_reference = repository_digests[0] - match = IMAGE_REFERENCE.fullmatch(registry_reference) - _require(match is not None, "staging push repository digest is invalid") - receipt = build_build_push_receipt(image_input, post_push_image=post_push_image) + _require( + validate_image_configuration(image_input, pre_push_image) == observed, + "staging candidate configuration drifted before push", + ) + _require( + _target_repository_digests(pre_push_image) == [], + "staging candidate already has a repository digest before push", + ) + push_attempted = True + record( + "push", + "attempted", + registry={"push_attempted": True, "action": "indeterminate"}, + ) + push_error: BaseException | None = None + try: + pushed = _run_docker( + docker_binary, + docker_host, + docker_config, + ["image", "push", "--quiet", candidate_reference], + check=True, + timeout=1800, + authority=authority, + ) + _bounded_docker_output(pushed, "staging image push") + except (OSError, subprocess.SubprocessError, PackageError) as exc: + push_error = exc + observed_digest = _wait_registry_candidate_digest(gcloud_binary, candidate_tag) + _require(observed_digest is not None, "staging push produced no observable immutable candidate") + remote_image = _pull_validated_registry_candidate( + image_input, + observed, + observed_digest, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + authority=authority, + ) + record( + "remote_readback", + "recovered" if push_error is not None else "pass", + registry={ + "action": "reconciled_after_attempt" if push_error is not None else "fresh_push", + "observed_digest": observed_digest, + }, + ) + receipt = build_build_push_receipt(image_input, post_push_image=remote_image) + + current_phase = "receipt" + publish_build_push_receipt( + output_receipt, + receipt, + image_input=image_input, + outside_context=context, + ) + record("receipt", "pass", release={"build_push_receipt_present": True}) except (OSError, subprocess.SubprocessError, PackageError) as exc: + registry_updates: dict[str, Any] = {} + if observed_digest is not None: + registry_updates["observed_digest"] = observed_digest + if push_attempted and outcome["registry"]["action"] not in {"fresh_push", "reconciled_after_attempt"}: + registry_updates["action"] = "indeterminate" + try: + outcome = advance_publish_outcome( + outcome, + phase=current_phase, + event_status="fail", + status="fail", + failure_phase=current_phase, + registry=registry_updates or None, + ) + publish_publish_outcome(outcome_journal, outcome, create=False) + except (OSError, PackageError) as journal_error: + raise PackageError("staging image build/push failed and outcome journaling failed") from journal_error + if current_phase == "receipt" and isinstance(exc, PackageError): + raise raise PackageError("staging image build/push failed") from exc - publish_build_push_receipt( - output_receipt, - receipt, - image_input=image_input, - outside_context=context, - ) return receipt @@ -3065,6 +3609,235 @@ def publish_build_push_receipt( os.close(parent_descriptor) +def publish_publish_outcome(path: Path, value: dict[str, Any], *, create: bool) -> None: + """Atomically create or replace the non-authority publish outcome journal.""" + + validate_publish_outcome(value) + _require(path.is_absolute() and path.name not in {"", ".", ".."}, "publish outcome path is invalid") + try: + payload = (json.dumps(value, allow_nan=False, indent=2, sort_keys=True) + "\n").encode("utf-8") + except (TypeError, ValueError) as exc: + raise PackageError("cannot encode publish outcome") from exc + _require(len(payload) <= MAX_PUBLISH_OUTCOME_BYTES, "publish outcome is too large") + parent_descriptor = _open_private_operator_directory(path.parent, "publish outcome parent") + staged_name = f".{path.name}.{uuid.uuid4().hex}.tmp" + descriptor: int | None = None + try: + existing = _directory_entry_stat(parent_descriptor, path.name) + if create: + _require(existing is None, "publish outcome already exists") + else: + _require( + existing is not None + and stat.S_ISREG(existing.st_mode) + and existing.st_uid == os.geteuid() + and stat.S_IMODE(existing.st_mode) == 0o600, + "publish outcome update target is unsafe", + ) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(staged_name, flags, 0o600, dir_fd=parent_descriptor) + os.fchmod(descriptor, 0o600) + with os.fdopen(descriptor, "wb", closefd=False) as handle: + handle.write(payload) + handle.flush() + os.fsync(descriptor) + if create: + os.link( + staged_name, + path.name, + src_dir_fd=parent_descriptor, + dst_dir_fd=parent_descriptor, + follow_symlinks=False, + ) + os.unlink(staged_name, dir_fd=parent_descriptor) + else: + os.replace( + staged_name, + path.name, + src_dir_fd=parent_descriptor, + dst_dir_fd=parent_descriptor, + ) + observed = os.stat(path.name, dir_fd=parent_descriptor, follow_symlinks=False) + _require( + stat.S_ISREG(observed.st_mode) + and observed.st_uid == os.geteuid() + and stat.S_IMODE(observed.st_mode) == 0o600 + and observed.st_size == len(payload), + "published outcome posture drifted", + ) + _fsync_directory_descriptor(parent_descriptor) + except OSError as exc: + raise PackageError("cannot publish outcome journal") from exc + finally: + if descriptor is not None: + os.close(descriptor) + staged = _directory_entry_stat(parent_descriptor, staged_name) + if staged is not None: + try: + os.unlink(staged_name, dir_fd=parent_descriptor) + _fsync_directory_descriptor(parent_descriptor) + except OSError as exc: + os.close(parent_descriptor) + raise PackageError("cannot clean staged outcome journal") from exc + os.close(parent_descriptor) + + +def load_publish_outcome(path: Path, *, image_input: dict[str, Any] | None = None) -> dict[str, Any]: + _require(path.is_absolute() and path.name not in {"", ".", ".."}, "publish outcome path is invalid") + parent_descriptor = _open_private_operator_directory(path.parent, "publish outcome parent") + descriptor: int | None = None + try: + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(path.name, flags, dir_fd=parent_descriptor) + before = os.fstat(descriptor) + _require( + stat.S_ISREG(before.st_mode) + and before.st_uid == os.geteuid() + and stat.S_IMODE(before.st_mode) == 0o600 + and before.st_size <= MAX_PUBLISH_OUTCOME_BYTES, + "publish outcome file posture is unsafe", + ) + chunks: list[bytes] = [] + remaining = MAX_PUBLISH_OUTCOME_BYTES + 1 + while remaining > 0: + chunk = os.read(descriptor, min(64 * 1024, remaining)) + if not chunk: + break + chunks.append(chunk) + remaining -= len(chunk) + payload = b"".join(chunks) + after = os.fstat(descriptor) + named = os.stat(path.name, dir_fd=parent_descriptor, follow_symlinks=False) + _require( + len(payload) <= MAX_PUBLISH_OUTCOME_BYTES + and (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns) + == (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns) + and (after.st_dev, after.st_ino) == (named.st_dev, named.st_ino) + and len(payload) == after.st_size, + "publish outcome changed while it was read", + ) + except (OSError, UnicodeError) as exc: + raise PackageError("cannot read publish outcome") from exc + finally: + if descriptor is not None: + os.close(descriptor) + os.close(parent_descriptor) + try: + value = _strict_json_loads(payload.decode("utf-8"), "publish outcome") + except UnicodeError as exc: + raise PackageError("cannot decode publish outcome") from exc + _require(isinstance(value, dict), "publish outcome must be a JSON object") + validate_publish_outcome(value, image_input=image_input) + return value + + +def complete_publish_outcome( + *, + image_input_path: Path, + outcome_journal: Path, + build_push_receipt_path: Path, + release_path: Path, + build_outcome: str, + finalize_outcome: str, + cleanup_outcome: str, +) -> dict[str, Any]: + """Finalize the mutation journal without masking an earlier workflow failure.""" + + allowed_step_outcomes = {"success", "failure", "cancelled", "skipped"} + _require( + {build_outcome, finalize_outcome, cleanup_outcome} <= allowed_step_outcomes, + "workflow step outcome is invalid", + ) + image_input = load_json(image_input_path, "image input") + outcome = load_publish_outcome(outcome_journal, image_input=image_input) + _require(outcome["status"] != "pass", "publish outcome is already complete") + + release_updates = { + "build_push_receipt_present": False, + "bundle_present": False, + "release_sha256": None, + } + evidence_failure: str | None = None + receipt: dict[str, Any] | None = None + release: dict[str, Any] | None = None + if build_push_receipt_path.is_file() and not build_push_receipt_path.is_symlink(): + try: + receipt = load_private_json(build_push_receipt_path, "build/push receipt") + validate_build_push_receipt(receipt, image_input=image_input) + _require( + receipt["registry"]["manifest_digest"] == outcome["registry"]["observed_digest"], + "build/push receipt differs from publish outcome", + ) + release_updates["build_push_receipt_present"] = True + except PackageError: + receipt = None + evidence_failure = "receipt_validation" + + if release_path.is_file() and not release_path.is_symlink(): + try: + release = load_json(release_path, "release descriptor") + validate_release_descriptor(release) + _require( + receipt is not None + and release["build_push_receipt"] == receipt + and release["image_input"] == image_input + and release["image"]["digest"] == outcome["registry"]["observed_digest"], + "release descriptor differs from publish outcome", + ) + release_updates.update( + { + "bundle_present": True, + "release_sha256": release["release_sha256"], + } + ) + except PackageError: + release = None + evidence_failure = evidence_failure or "release_validation" + + if build_outcome == "success" and release_updates["build_push_receipt_present"] is not True: + evidence_failure = evidence_failure or "receipt_missing" + if finalize_outcome == "success" and release_updates["bundle_present"] is not True: + evidence_failure = evidence_failure or "release_missing" + + failure_phase = outcome["failure_phase"] + if failure_phase is None and build_outcome != "success": + failure_phase = { + "failure": "build_push", + "cancelled": "workflow_cancelled", + "skipped": "pre_publish", + }[build_outcome] + if failure_phase is None and finalize_outcome != "success": + failure_phase = "finalize" + if failure_phase is None and evidence_failure is not None: + failure_phase = evidence_failure + if failure_phase is None and cleanup_outcome != "success": + failure_phase = "cleanup" + passing = ( + failure_phase is None + and build_outcome == "success" + and finalize_outcome == "success" + and cleanup_outcome == "success" + and release_updates["build_push_receipt_present"] is True + and release_updates["bundle_present"] is True + and _is_sha256(release_updates["release_sha256"]) + ) + completed = advance_publish_outcome( + outcome, + phase="complete", + event_status="pass" if passing else "fail", + status="pass" if passing else "fail", + failure_phase=None if passing else failure_phase or "completion", + cleanup={"docker_config": "pass" if cleanup_outcome == "success" else "fail"}, + release=release_updates, + ) + publish_publish_outcome(outcome_journal, completed, create=False) + return completed + + def _remove_published_bundle(output_bundle: Path, expected_identity: tuple[int, int]) -> None: try: observed = output_bundle.stat(follow_symlinks=False) @@ -3289,8 +4062,21 @@ def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: build_push.add_argument("--docker-binary", type=Path, required=True) build_push.add_argument("--docker-host", required=True) build_push.add_argument("--docker-config", type=Path, required=True) + build_push.add_argument("--gcloud-binary", type=Path, required=True) build_push.add_argument("--output-receipt", type=Path, required=True) + build_push.add_argument("--outcome-journal", type=Path, required=True) build_push.add_argument("--execute-staging-push", action="store_true") + init_outcome = subparsers.add_parser("init-outcome") + init_outcome.add_argument("--image-input", type=Path, required=True) + init_outcome.add_argument("--output-journal", type=Path, required=True) + complete_outcome = subparsers.add_parser("complete-outcome") + complete_outcome.add_argument("--image-input", type=Path, required=True) + complete_outcome.add_argument("--outcome-journal", type=Path, required=True) + complete_outcome.add_argument("--build-push-receipt", type=Path, required=True) + complete_outcome.add_argument("--release", type=Path, required=True) + complete_outcome.add_argument("--build-outcome", required=True) + complete_outcome.add_argument("--finalize-outcome", required=True) + complete_outcome.add_argument("--cleanup-outcome", required=True) finalize = subparsers.add_parser("finalize") finalize.add_argument("--image-input", type=Path, required=True) finalize.add_argument("--build-push-receipt", type=Path, required=True) @@ -3375,9 +4161,38 @@ def main(argv: list[str] | None = None) -> int: docker_binary=args.docker_binary, docker_host=args.docker_host, docker_config=args.docker_config, + gcloud_binary=args.gcloud_binary, output_receipt=args.output_receipt.absolute(), + outcome_journal=args.outcome_journal.absolute(), execute_staging_push=args.execute_staging_push, ) + elif args.command == "init-outcome": + image_input = load_json(args.image_input, "image input") + result = build_initial_publish_outcome(image_input) + publish_publish_outcome(args.output_journal.absolute(), result, create=True) + elif args.command == "complete-outcome": + result = complete_publish_outcome( + image_input_path=args.image_input, + outcome_journal=args.outcome_journal.absolute(), + build_push_receipt_path=args.build_push_receipt.absolute(), + release_path=args.release.absolute(), + build_outcome=args.build_outcome, + finalize_outcome=args.finalize_outcome, + cleanup_outcome=args.cleanup_outcome, + ) + if result["status"] != "pass": + print( + json.dumps( + { + "status": "fail", + "schema": result["schema"], + "failure_phase": result["failure_phase"], + }, + sort_keys=True, + ), + file=sys.stderr, + ) + return 65 elif args.command == "finalize": image_input = load_json(args.image_input, "image input") build_push_receipt = load_private_json( diff --git a/tests/test_gcp_leoclean_nosend_package.py b/tests/test_gcp_leoclean_nosend_package.py index 1dab40e..79cc012 100644 --- a/tests/test_gcp_leoclean_nosend_package.py +++ b/tests/test_gcp_leoclean_nosend_package.py @@ -19,6 +19,9 @@ from ops import gcp_leoclean_nosend_package as package ROOT = Path(__file__).resolve().parents[1] REVISION = package._git_commit(ROOT) IMAGE_DIGEST = "2" * 64 +LOCAL_CONFIG_DIGEST = "sha256:" + "3" * 64 +REMOTE_CONFIG_DIGEST = "sha256:" + "6" * 64 +ROOTFS_LAYERS = ["sha256:" + "7" * 64, "sha256:" + "8" * 64] CLAIM_CEILING = "Offline package proof only; live GCP identity, database access, restart, and parity remain unproven." @@ -394,7 +397,8 @@ def docker_inspection_fixture( image_input: dict[str, object], reference: str, *, - config_digest: str = "sha256:" + "3" * 64, + config_digest: str = LOCAL_CONFIG_DIGEST, + rootfs_layers: list[str] | None = None, ) -> dict[str, object]: runtime = copy.deepcopy(package.IMAGE_RUNTIME_CONFIG) healthcheck = runtime["healthcheck"] @@ -404,6 +408,7 @@ def docker_inspection_fixture( "Architecture": "amd64", "Os": "linux", "Variant": None, + "RootFS": {"Type": "layers", "Layers": list(rootfs_layers or ROOTFS_LAYERS)}, "Config": { "Labels": package.expected_image_labels(image_input), "User": runtime["user"], @@ -447,6 +452,32 @@ def docker_authority(tmp_path: Path) -> tuple[Path, str, Path]: return docker_binary, "unix:///tmp/livingip-docker.sock", docker_config +def publish_outcome_authority( + tmp_path: Path, + image_input: dict[str, object], +) -> tuple[Path, Path]: + gcloud_binary = tmp_path / "bin" / "gcloud" + gcloud_binary.parent.mkdir(exist_ok=True) + gcloud_binary.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + gcloud_binary.chmod(0o755) + outcome_journal = tmp_path / "publish-outcome.json" + package.publish_publish_outcome( + outcome_journal, + package.build_initial_publish_outcome(image_input), + create=True, + ) + return gcloud_binary, outcome_journal + + +def mock_fresh_registry_reconcile( + monkeypatch: pytest.MonkeyPatch, + *, + manifest_digest: str = "sha256:" + IMAGE_DIGEST, +) -> None: + monkeypatch.setattr(package, "_registry_candidate_digest", lambda *_args: None) + monkeypatch.setattr(package, "_wait_registry_candidate_digest", lambda *_args: manifest_digest) + + def docker_inventory_fixture( reference: str, *, @@ -505,19 +536,28 @@ def mocked_build_push_docker( *, post_push_references: list[str] | None = None, fail_after_side_effect: str | None = None, + remote_config_digest: str = LOCAL_CONFIG_DIGEST, + remote_rootfs_layers: list[str] | None = None, + remote_verification_valid: bool = True, ) -> tuple[object, dict[str, object], list[tuple[list[str], dict[str, object]]]]: - config_digest = "sha256:" + "3" * 64 + config_digest = LOCAL_CONFIG_DIGEST exact_reference = f"{package.IMAGE_REPOSITORY}@sha256:{IMAGE_DIGEST}" references = list(post_push_references if post_push_references is not None else [exact_reference]) build_tag = f"input-{image_input['input_sha256']}" build_reference = f"{package.LOCAL_BUILD_REPOSITORY}:{build_tag}" - candidate_tag = f"candidate-{config_digest.removeprefix('sha256:')}" + candidate_tag = f"candidate-{image_input['input_sha256']}" candidate_reference = f"{package.IMAGE_REPOSITORY}:{candidate_tag}" state: dict[str, object] = {"build": False, "candidate": False, "digests": False, "pushes": 0} calls: list[tuple[list[str], dict[str, object]]] = [] def inspection(reference: str) -> bytes: - value = docker_inspection_fixture(image_input, reference, config_digest=config_digest) + is_remote = "@sha256:" in reference + value = docker_inspection_fixture( + image_input, + reference, + config_digest=remote_config_digest if is_remote else config_digest, + rootfs_layers=remote_rootfs_layers if is_remote else ROOTFS_LAYERS, + ) value["RepoDigests"] = references if state["digests"] else [] return json.dumps([value]).encode() @@ -576,6 +616,52 @@ def mocked_build_push_docker( stdout=("sha256:" + "9" * 64 + "\n").encode(), stderr=b"", ) + if operation[0] == "pull": + assert operation[1:4] == ["--quiet", "--platform", package.PLATFORM] + assert operation[4] in references + state["digests"] = True + return subprocess.CompletedProcess(arguments, 0, stdout=b"pulled\n", stderr=b"") + if operation[0] == "run": + assert operation == [ + "run", + "--rm", + "--pull=never", + "--platform", + package.PLATFORM, + "--network=none", + "--read-only", + "--cap-drop=ALL", + "--security-opt=no-new-privileges:true", + "--pids-limit=64", + f"--user={package.RUNTIME_UID}:{package.RUNTIME_GID}", + exact_reference, + "verify-build", + image_input["runtime"]["teleo_git_head"], + image_input["runtime"]["artifact_sha256"], + image_input["identity"]["bundle_sha256"], + image_input["input_sha256"], + ] + verification = ( + { + "schema": "livingip.leocleanNoSendContainerVerification.v1", + "status": "pass", + "teleo_git_head": image_input["runtime"]["teleo_git_head"], + "artifact_sha256": image_input["runtime"]["artifact_sha256"], + "identity_sha256": image_input["identity"]["bundle_sha256"], + "input_sha256": image_input["input_sha256"], + "python_version": package.PYTHON_VERSION, + "postgres_version": package.POSTGRES_VERSION, + "cloudsql_ca_sha256": package.CA_SHA256, + } + if remote_verification_valid + else {"status": "fail"} + ) + return subprocess.CompletedProcess( + arguments, + 0, + stdout=(json.dumps(verification) + "\n").encode(), + stderr=b"", + ) if operation[:2] == ["image", "inspect"]: reference = operation[2] present = ( @@ -602,6 +688,98 @@ def mocked_build_push_docker( return fake_run, state, calls +def test_registry_candidate_lookup_uses_exact_resource_shape_and_command( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + candidate_tag = "candidate-" + "a" * 64 + digest = "b" * 64 + expected_tag = f"{package.ARTIFACT_PACKAGE_RESOURCE}/tags/{candidate_tag}" + fixture = [ + { + "tag": expected_tag, + "version": f"{package.ARTIFACT_PACKAGE_RESOURCE}/versions/sha256:{digest}", + } + ] + observed: dict[str, object] = {} + + def fake_gcloud( + binary: Path, + arguments: list[str], + *, + timeout: int = 60, + ) -> subprocess.CompletedProcess[bytes]: + observed.update({"binary": binary, "arguments": arguments, "timeout": timeout}) + return subprocess.CompletedProcess(arguments, 0, stdout=json.dumps(fixture).encode(), stderr=b"") + + monkeypatch.setattr(package, "_run_gcloud", fake_gcloud) + gcloud_binary = tmp_path / "gcloud" + + assert package._registry_candidate_digest(gcloud_binary, candidate_tag) == f"sha256:{digest}" + assert observed == { + "binary": gcloud_binary, + "arguments": [ + "artifacts", + "docker", + "tags", + "list", + package.IMAGE_REPOSITORY, + "--project", + package.PROJECT, + f"--filter=tag={expected_tag}", + "--format=json(tag,version)", + "--limit=2", + ], + "timeout": 60, + } + assert "--location" not in observed["arguments"] + + +def test_registry_candidate_lookup_rejects_mixed_exact_and_unexpected_rows( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + candidate_tag = "candidate-" + "a" * 64 + digest = "b" * 64 + expected_tag = f"{package.ARTIFACT_PACKAGE_RESOURCE}/tags/{candidate_tag}" + rows = [ + { + "tag": expected_tag, + "version": f"{package.ARTIFACT_PACKAGE_RESOURCE}/versions/sha256:{digest}", + }, + { + "tag": f"{package.ARTIFACT_PACKAGE_RESOURCE}/tags/candidate-{'c' * 64}", + "version": f"{package.ARTIFACT_PACKAGE_RESOURCE}/versions/sha256:{'d' * 64}", + }, + ] + monkeypatch.setattr( + package, + "_run_gcloud", + lambda *_args, **_kwargs: subprocess.CompletedProcess( + [], + 0, + stdout=json.dumps(rows).encode(), + stderr=b"", + ), + ) + + with pytest.raises(package.PackageError, match="unexpected tag"): + package._registry_candidate_digest(tmp_path / "gcloud", candidate_tag) + + +def test_publish_outcome_loader_handles_short_reads( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + _gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) + expected = package.build_initial_publish_outcome(image_input) + original_read = os.read + monkeypatch.setattr(package.os, "read", lambda descriptor, size: original_read(descriptor, min(size, 7))) + + assert package.load_publish_outcome(outcome_journal, image_input=image_input) == expected + + def test_image_input_binds_exact_runtime_identity_and_staging_target(tmp_path: Path) -> None: value = image_input_fixture(tmp_path) package.validate_image_input(value) @@ -1065,6 +1243,7 @@ def test_build_push_derives_receipt_and_retains_all_daemon_references( repo_root.mkdir() output = tmp_path / "build-push-receipt.json" docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) fake_run, state, calls = mocked_build_push_docker(image_input) reviewed: list[tuple[Path, Path, dict[str, object]]] = [] monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) @@ -1074,6 +1253,7 @@ def test_build_push_derives_receipt_and_retains_all_daemon_references( lambda root, observed_context, value: reviewed.append((root, observed_context, value)), ) monkeypatch.setattr(package.subprocess, "run", fake_run) + mock_fresh_registry_reconcile(monkeypatch) monkeypatch.setenv("DOCKER_TLS_VERIFY", "1") monkeypatch.setenv("BUILDKIT_PROGRESS", "plain") monkeypatch.setenv("BUILDX_BUILDER", "unreviewed") @@ -1084,13 +1264,24 @@ def test_build_push_derives_receipt_and_retains_all_daemon_references( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + gcloud_binary=gcloud_binary, output_receipt=output, + outcome_journal=outcome_journal, execute_staging_push=True, ) assert json.loads(output.read_text(encoding="utf-8")) == receipt assert receipt["registry"]["manifest_digest"] == f"sha256:{IMAGE_DIGEST}" assert receipt["registry"]["manifest_digest"] != "sha256:" + "9" * 64 + outcome = package.load_publish_outcome(outcome_journal, image_input=image_input) + assert outcome["candidate"]["tag"] == f"candidate-{image_input['input_sha256']}" + assert outcome["registry"] == { + "repository": package.IMAGE_REPOSITORY, + "preexisting": False, + "push_attempted": True, + "action": "fresh_push", + "observed_digest": f"sha256:{IMAGE_DIGEST}", + } assert reviewed == [(repo_root, context, image_input), (repo_root, context, image_input)] assert state == {"build": True, "candidate": True, "digests": True, "pushes": 1} build = next(operation for operation, _kwargs in calls if operation and operation[0] == "build") @@ -1134,7 +1325,9 @@ def test_build_push_requires_explicit_execution_flag_before_validation(tmp_path: docker_binary=tmp_path / "docker", docker_host="unix:///tmp/docker.sock", docker_config=tmp_path, + gcloud_binary=tmp_path / "gcloud", output_receipt=tmp_path / "receipt.json", + outcome_journal=tmp_path / "outcome.json", execute_staging_push=False, ) @@ -1156,6 +1349,7 @@ def test_build_push_rejects_writable_docker_config_before_docker( repo_root.mkdir() output = tmp_path / "writable-config-build-push-receipt.json" docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) docker_config.chmod(0o777) docker_calls: list[list[str]] = [] monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) @@ -1173,7 +1367,9 @@ def test_build_push_rejects_writable_docker_config_before_docker( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + gcloud_binary=gcloud_binary, output_receipt=output, + outcome_journal=outcome_journal, execute_staging_push=True, ) @@ -1282,6 +1478,7 @@ def test_build_push_rejects_replaced_docker_config_before_mutation( repo_root.mkdir() output = tmp_path / "replaced-config-build-push-receipt.json" docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) retained_config = tmp_path / "retained-docker-config" docker_calls: list[list[str]] = [] monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) @@ -1306,7 +1503,9 @@ def test_build_push_rejects_replaced_docker_config_before_mutation( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + gcloud_binary=gcloud_binary, output_receipt=output, + outcome_journal=outcome_journal, execute_staging_push=True, ) @@ -1335,6 +1534,7 @@ def test_build_push_rejects_receipt_parent_symlink_alias_before_docker( repo_root = tmp_path / "repo" repo_root.mkdir() docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) docker_calls: list[list[str]] = [] monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) @@ -1351,7 +1551,9 @@ def test_build_push_rejects_receipt_parent_symlink_alias_before_docker( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + gcloud_binary=gcloud_binary, output_receipt=output, + outcome_journal=outcome_journal, execute_staging_push=True, ) @@ -1380,11 +1582,13 @@ def test_build_push_rechecks_receipt_context_separation_at_publication( retained_parent = tmp_path / "retained-receipts" output = output_parent / "receipt.json" docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) fake_run, state, calls = mocked_build_push_docker(image_input) original_publish = package.publish_build_push_receipt monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) monkeypatch.setattr(package.subprocess, "run", fake_run) + mock_fresh_registry_reconcile(monkeypatch) def alias_parent_before_publish(*args: object, **kwargs: object) -> None: output_parent.rename(retained_parent) @@ -1400,7 +1604,9 @@ def test_build_push_rechecks_receipt_context_separation_at_publication( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + gcloud_binary=gcloud_binary, output_receipt=output, + outcome_journal=outcome_journal, execute_staging_push=True, ) @@ -1437,6 +1643,7 @@ def test_build_push_fails_closed_without_one_exact_post_push_digest( repo_root = tmp_path / "repo" repo_root.mkdir() docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) fake_run, state, _calls = mocked_build_push_docker( image_input, post_push_references=references, @@ -1444,6 +1651,12 @@ def test_build_push_fails_closed_without_one_exact_post_push_digest( monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) monkeypatch.setattr(package.subprocess, "run", fake_run) + monkeypatch.setattr(package, "_registry_candidate_digest", lambda *_args: None) + monkeypatch.setattr( + package, + "_wait_registry_candidate_digest", + lambda *_args: f"sha256:{IMAGE_DIGEST}" if references else None, + ) output = tmp_path / "failed-build-push-receipt.json" with pytest.raises(package.PackageError, match="build/push failed"): @@ -1453,7 +1666,9 @@ def test_build_push_fails_closed_without_one_exact_post_push_digest( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + gcloud_binary=gcloud_binary, output_receipt=output, + outcome_journal=outcome_journal, execute_staging_push=True, ) @@ -1462,7 +1677,7 @@ def test_build_push_fails_closed_without_one_exact_post_push_digest( assert state["candidate"] is True -@pytest.mark.parametrize("failure", ["build", "tag", "push"]) +@pytest.mark.parametrize("failure", ["build", "tag"]) def test_build_push_retains_daemon_references_when_cli_fails_after_side_effect( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1480,6 +1695,7 @@ def test_build_push_retains_daemon_references_when_cli_fails_after_side_effect( repo_root = tmp_path / "repo" repo_root.mkdir() docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) fake_run, state, calls = mocked_build_push_docker( image_input, fail_after_side_effect=failure, @@ -1487,6 +1703,7 @@ def test_build_push_retains_daemon_references_when_cli_fails_after_side_effect( monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) monkeypatch.setattr(package.subprocess, "run", fake_run) + mock_fresh_registry_reconcile(monkeypatch) output = tmp_path / "failed-side-effect-receipt.json" with pytest.raises(package.PackageError, match="build/push failed") as raised: @@ -1496,7 +1713,9 @@ def test_build_push_retains_daemon_references_when_cli_fails_after_side_effect( docker_binary=docker_binary, docker_host=docker_host, docker_config=docker_config, + gcloud_binary=gcloud_binary, output_receipt=output, + outcome_journal=outcome_journal, execute_staging_push=True, ) @@ -1504,14 +1723,188 @@ def test_build_push_retains_daemon_references_when_cli_fails_after_side_effect( assert not output.exists() assert state == { "build": True, - "candidate": failure in {"tag", "push"}, - "digests": failure == "push", - "pushes": 1 if failure == "push" else 0, + "candidate": failure == "tag", + "digests": False, + "pushes": 0, } removals = [operation for operation, _kwargs in calls if operation[:2] == ["image", "rm"]] assert removals == [] +def test_build_push_reuses_verified_candidate_despite_nondeterministic_rootfs( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + build_args = { + "ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"], + "IDENTITY_SHA256": image_input["identity"]["bundle_sha256"], + "INPUT_SHA256": image_input["input_sha256"], + "TELEO_REVISION": image_input["runtime"]["teleo_git_head"], + } + context = tmp_path / "context" + context.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + output = tmp_path / "reused-build-push-receipt.json" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) + fake_run, state, calls = mocked_build_push_docker( + image_input, + remote_config_digest=REMOTE_CONFIG_DIGEST, + remote_rootfs_layers=["sha256:" + "9" * 64], + ) + monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) + monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) + monkeypatch.setattr(package.subprocess, "run", fake_run) + monkeypatch.setattr( + package, + "_registry_candidate_digest", + lambda *_args: f"sha256:{IMAGE_DIGEST}", + ) + + receipt = package.build_and_push_staging_image( + repo_root, + context, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + gcloud_binary=gcloud_binary, + output_receipt=output, + outcome_journal=outcome_journal, + execute_staging_push=True, + ) + + assert receipt["local_image"]["config_digest"] == REMOTE_CONFIG_DIGEST + assert state == {"build": True, "candidate": False, "digests": True, "pushes": 0} + assert not any(operation[:2] == ["image", "push"] for operation, _kwargs in calls) + assert any(operation and operation[0] == "pull" for operation, _kwargs in calls) + assert any(operation and operation[0] == "run" for operation, _kwargs in calls) + outcome = package.load_publish_outcome(outcome_journal, image_input=image_input) + assert outcome["candidate"] == { + "tag": f"candidate-{image_input['input_sha256']}", + "reference": f"{package.IMAGE_REPOSITORY}:candidate-{image_input['input_sha256']}", + "local_build_config_digest": LOCAL_CONFIG_DIGEST, + } + assert outcome["registry"] == { + "repository": package.IMAGE_REPOSITORY, + "preexisting": True, + "push_attempted": False, + "action": "reused", + "observed_digest": f"sha256:{IMAGE_DIGEST}", + } + + +def test_build_push_rejects_preexisting_failed_runtime_verification_without_claiming_reuse( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + build_args = { + "ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"], + "IDENTITY_SHA256": image_input["identity"]["bundle_sha256"], + "INPUT_SHA256": image_input["input_sha256"], + "TELEO_REVISION": image_input["runtime"]["teleo_git_head"], + } + context = tmp_path / "context" + context.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + output = tmp_path / "mismatched-build-push-receipt.json" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) + fake_run, state, calls = mocked_build_push_docker( + image_input, + remote_config_digest=REMOTE_CONFIG_DIGEST, + remote_verification_valid=False, + ) + monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) + monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) + monkeypatch.setattr(package.subprocess, "run", fake_run) + monkeypatch.setattr( + package, + "_registry_candidate_digest", + lambda *_args: f"sha256:{IMAGE_DIGEST}", + ) + + with pytest.raises(package.PackageError, match="build/push failed"): + package.build_and_push_staging_image( + repo_root, + context, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + gcloud_binary=gcloud_binary, + output_receipt=output, + outcome_journal=outcome_journal, + execute_staging_push=True, + ) + + assert not output.exists() + assert state == {"build": True, "candidate": False, "digests": True, "pushes": 0} + assert not any(operation[:2] == ["image", "push"] for operation, _kwargs in calls) + outcome = package.load_publish_outcome(outcome_journal, image_input=image_input) + assert outcome["status"] == "fail" + assert outcome["registry"]["preexisting"] is True + assert outcome["registry"]["observed_digest"] == f"sha256:{IMAGE_DIGEST}" + assert outcome["registry"]["action"] == "observed" + assert all(event["status"] != "reused" for event in outcome["events"]) + + +def test_post_push_validation_failure_retains_mutation_evidence( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + image_input = image_input_fixture(tmp_path) + build_args = { + "ARTIFACT_SHA256": image_input["runtime"]["artifact_sha256"], + "IDENTITY_SHA256": image_input["identity"]["bundle_sha256"], + "INPUT_SHA256": image_input["input_sha256"], + "TELEO_REVISION": image_input["runtime"]["teleo_git_head"], + } + context = tmp_path / "context" + context.mkdir() + repo_root = tmp_path / "repo" + repo_root.mkdir() + output = tmp_path / "post-push-failure-receipt.json" + docker_binary, docker_host, docker_config = docker_authority(tmp_path) + gcloud_binary, outcome_journal = publish_outcome_authority(tmp_path, image_input) + fake_run, state, calls = mocked_build_push_docker( + image_input, + remote_verification_valid=False, + ) + monkeypatch.setattr(package, "validate_prepared_image_context", lambda _context: (image_input, build_args)) + monkeypatch.setattr(package, "validate_reviewed_context_source", lambda *_args: None) + monkeypatch.setattr(package.subprocess, "run", fake_run) + mock_fresh_registry_reconcile(monkeypatch) + + with pytest.raises(package.PackageError, match="build/push failed"): + package.build_and_push_staging_image( + repo_root, + context, + docker_binary=docker_binary, + docker_host=docker_host, + docker_config=docker_config, + gcloud_binary=gcloud_binary, + output_receipt=output, + outcome_journal=outcome_journal, + execute_staging_push=True, + ) + + assert not output.exists() + assert state == {"build": True, "candidate": True, "digests": True, "pushes": 1} + assert any(operation[:2] == ["image", "push"] for operation, _kwargs in calls) + outcome = package.load_publish_outcome(outcome_journal, image_input=image_input) + assert outcome["status"] == "fail" + assert outcome["registry"] == { + "repository": package.IMAGE_REPOSITORY, + "preexisting": False, + "push_attempted": True, + "action": "indeterminate", + "observed_digest": f"sha256:{IMAGE_DIGEST}", + } + + def test_build_push_receipt_publication_is_atomic_repeatable_and_no_clobber(tmp_path: Path) -> None: image_input = image_input_fixture(tmp_path) receipt = build_push_receipt_fixture(image_input) diff --git a/tests/test_gcp_leoclean_nosend_release_workflow.py b/tests/test_gcp_leoclean_nosend_release_workflow.py index 2bce3ca..1a01bee 100644 --- a/tests/test_gcp_leoclean_nosend_release_workflow.py +++ b/tests/test_gcp_leoclean_nosend_release_workflow.py @@ -35,6 +35,8 @@ def test_release_workflow_is_manual_main_only_and_serialized() -> None: assert 'test "${GITHUB_EVENT_NAME}" = "workflow_dispatch"' in guard assert 'test "${GITHUB_REF}" = "refs/heads/main"' in guard assert 'test "${GITHUB_REF_TYPE}" = "branch"' in guard + assert 'test "${GITHUB_REPOSITORY}" = "living-ip/teleo-infrastructure"' in guard + assert '"living-ip/teleo-infrastructure/.github/workflows/gcp-leoclean-nosend-release.yml@refs/heads/main"' in guard def test_release_workflow_checks_out_exact_clean_revision_without_persisted_token() -> None: @@ -56,11 +58,15 @@ def test_release_workflow_smokes_before_requesting_cloud_identity() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") smoke = workflow.index("Smoke exact no-send OCI surface") + prepare = workflow.index("Prepare exact candidate context") + initialize = workflow.index("Initialize pre-mutation publish outcome") auth = workflow.index("Authenticate to Google Cloud through WIF") push = workflow.index("Build and push one immutable candidate") finalize = workflow.index("Finalize and validate release v3 bundle") - upload = workflow.index("Upload exact build and release evidence") - assert smoke < auth < push < finalize < upload + cleanup = workflow.index("Remove ephemeral Docker authentication") + complete = workflow.index("Finalize publish outcome") + upload = workflow.index("Upload exact available build and release evidence") + assert smoke < prepare < initialize < auth < push < finalize < cleanup < complete < upload assert "scripts/run_leoclean_nosend_runtime_canary.sh" in workflow assert "scripts/run_gcp_leoclean_nosend_oci_smoke.py" in workflow assert 'assert smoke["input_sha256"] == image_input["input_sha256"]' in workflow @@ -93,6 +99,22 @@ def test_release_workflow_uses_exact_wif_artifact_builder_without_repository_sec } +def test_release_workflow_keeps_runner_temp_out_of_job_environment() -> None: + workflow = load_workflow() + jobs = workflow["jobs"] + assert isinstance(jobs, dict) + publish = jobs["publish"] + assert isinstance(publish, dict) + root_env = workflow["env"] + job_env = publish.get("env", {}) + + assert isinstance(root_env, dict) + assert isinstance(job_env, dict) + assert all("runner.temp" not in value for value in [*root_env.values(), *job_env.values()]) + initialize = workflow_step("Initialize private release paths")["run"] + assert 'release_root="${RUNNER_TEMP}/leoclean-nosend-release"' in initialize + + def test_release_workflow_requires_immutable_repository_and_package_owned_candidate_push() -> None: workflow = WORKFLOW.read_text(encoding="utf-8") immutable = workflow_step("Require immutable fixed Artifact Registry repository")["run"] @@ -104,27 +126,39 @@ def test_release_workflow_requires_immutable_repository_and_package_owned_candid assert "--execute-staging-push" in push assert "--docker-binary /usr/bin/docker" in push assert "--docker-host unix:///var/run/docker.sock" in push + assert '--gcloud-binary "$(command -v gcloud)"' in push + assert '--outcome-journal "${PUBLISH_OUTCOME}"' in push assert "docker push" not in workflow assert "docker tag" not in workflow assert "candidate-" not in workflow -def test_release_workflow_finalizes_v3_and_uploads_only_allowlisted_evidence() -> None: +def test_release_workflow_always_finalizes_journal_and_uploads_only_allowlisted_evidence() -> None: finalize = workflow_step("Finalize and validate release v3 bundle")["run"] - upload = workflow_step("Upload exact build and release evidence") + cleanup = workflow_step("Remove ephemeral Docker authentication") + complete = workflow_step("Finalize publish outcome") + upload = workflow_step("Upload exact available build and release evidence") paths = upload["with"]["path"].splitlines() assert "ops/gcp_leoclean_nosend_package.py finalize" in finalize assert "ops/gcp_leoclean_nosend_package.py validate" in finalize assert 'assert release["schema"] == "livingip.leocleanNoSendRelease.v3"' in finalize + assert cleanup["if"] == "always()" + assert complete["if"] == "always() && steps.init_outcome.outcome == 'success'" + assert "ops/gcp_leoclean_nosend_package.py complete-outcome" in complete["run"] + assert '--build-outcome "${{ steps.build_push.outcome }}"' in complete["run"] + assert '--finalize-outcome "${{ steps.finalize.outcome }}"' in complete["run"] + assert '--cleanup-outcome "${{ steps.cleanup.outcome }}"' in complete["run"] + assert upload["if"] == "always()" assert paths == [ - "${{ env.RUNTIME_RECEIPT }}", - "${{ env.OCI_SMOKE_RECEIPT }}", - "${{ env.BUILD_PUSH_RECEIPT }}", - "${{ env.RELEASE_BUNDLE }}/release.json", - "${{ env.RELEASE_BUNDLE }}/leoclean-gcp-nosend.service", + "${{ runner.temp }}/leoclean-nosend-release/evidence/runtime-receipt.json", + "${{ runner.temp }}/leoclean-nosend-release/evidence/oci-smoke.json", + "${{ runner.temp }}/leoclean-nosend-release/evidence/publish-outcome.json", + "${{ runner.temp }}/leoclean-nosend-release/receipts/build-push-receipt.json", + "${{ runner.temp }}/leoclean-nosend-release/final/release-v3/release.json", + "${{ runner.temp }}/leoclean-nosend-release/final/release-v3/leoclean-gcp-nosend.service", ] - assert upload["with"]["if-no-files-found"] == "error" + assert upload["with"]["if-no-files-found"] == "warn" assert upload["with"]["retention-days"] == "30" assert "*" not in upload["with"]["path"] assert "google-github-actions" not in upload["with"]["path"]