From 4e108771f8986598621fc08bc473eb13bd4fb160 Mon Sep 17 00:00:00 2001 From: latte Date: Wed, 14 Oct 2026 18:02:43 +0200 Subject: [PATCH] ci: build under a per-run local tag, untag registry refs only, gate the image push on every test job --- .gitea/workflows/ci.yml | 36 ++++++++++++++++++-------- scripts/ci/cleanup_job_resources.sh | 12 +++++---- tests/security/test_ci_workflows.py | 40 +++++++++++++++++++++++++---- 3 files changed, 67 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6a669b7..77de835 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -7,7 +7,9 @@ name: ci # test-unit pytest (coverage gate from pyproject.toml: 90 percent) # test-integration pytest against a throwaway Gitea in Docker # test-mcp MCP smoke tests, including against the freshly built image -# build-and-push build and scan the image; push it on push events (needs lint + test-unit) +# build-and-push build and scan the image; push it on push events (needs lint, test-unit, +# test-integration, test-mcp and security, so :dev and :latest are never +# pushed while any of them is red) # docs-check docs drift check # security security test suite, pip-audit, ruff bandit rules # @@ -21,7 +23,10 @@ name: ci # # Docker hygiene on the shared runner: every resource this workflow creates has # a per-run name (aegis-ci--...) and is removed by an `if: always()` -# step that deletes exactly those names. Nothing here ever prunes. +# step that deletes exactly those names. Registry tags (:sha-<12>, :dev, :latest, :main) +# are shared with other users of the daemon: they are applied only immediately before the +# push and only untagged afterwards, never used to remove containers. Nothing here ever +# prunes. on: push: @@ -153,6 +158,8 @@ jobs: env: AEGIS_INTEGRATION: "1" AEGIS_IT_NETWORK_MODE: container + # A runner that cannot start must fail the job, not xfail the Actions scenario. + AEGIS_IT_REQUIRE_RUNNER: "1" run: uv run pytest tests/integration -m integration --no-cov - name: Cleanup if: always() @@ -204,8 +211,8 @@ jobs: run: bash scripts/ci/cleanup_job_resources.sh "${MCP_IMAGE}" # --------------------------------------------------------------------------- - # 6. Build the image from the checked-out commit. On push events it is also - # pushed: + # 6. Build the image from the checked-out commit under a per-run local tag + # (aegis-ci--build:). On push events it is also tagged and pushed: # :sha-<12> every branch (immutable, traceable to the commit; lets any # feature branch image be pulled and tested) # :dev additionally on dev @@ -214,7 +221,7 @@ jobs: # outputs and the job summary. # --------------------------------------------------------------------------- build-and-push: - needs: [lint, test-unit] + needs: [lint, test-unit, test-integration, test-mcp, security] if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 30 @@ -233,6 +240,8 @@ jobs: run: | IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}" SHA_TAG="${IMAGE}:sha-${GITHUB_SHA::12}" + # Local build tag: unique per run, never a registry tag. + BUILD_TAG="aegis-ci-${GITHUB_RUN_ID}-build:${GITHUB_SHA}" EXTRA_TAGS="" if [ "${GITHUB_EVENT_NAME}" = "push" ]; then if [ "${GITHUB_REF_NAME}" = "main" ]; then @@ -244,6 +253,7 @@ jobs: { echo "image=${IMAGE}" echo "sha_tag=${SHA_TAG}" + echo "build_tag=${BUILD_TAG}" echo "extra_tags=${EXTRA_TAGS}" } >> "$GITHUB_OUTPUT" @@ -253,11 +263,11 @@ jobs: docker build -f docker/Dockerfile \ --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ --label "org.opencontainers.image.source=https://git.hiddenden.cafe/${GITHUB_REPOSITORY}" \ - -t "${{ steps.meta.outputs.sha_tag }}" . + -t "${{ steps.meta.outputs.build_tag }}" . - name: Smoke-test image shell: bash - run: docker run --rm "${{ steps.meta.outputs.sha_tag }}" version + run: docker run --rm "${{ steps.meta.outputs.build_tag }}" version # Vulnerability scan of the image that is about to be pushed: pinned Trivy # (sha256 verified), fails on fixable HIGH and CRITICAL findings. Waivers live @@ -271,7 +281,7 @@ jobs: - name: Scan image shell: bash - run: bash scripts/ci/scan_image.sh "${{ steps.meta.outputs.sha_tag }}" + run: bash scripts/ci/scan_image.sh "${{ steps.meta.outputs.build_tag }}" # No third-party action sees the registry token: the CLI reads it from stdin. The # credentials go to a per-job config directory (DOCKER_CONFIG), never into the shared @@ -290,12 +300,14 @@ jobs: printf '%s' "${REGISTRY_TOKEN}" | DOCKER_CONFIG="${config_dir}" \ docker login git.hiddenden.cafe --username "${REGISTRY_USER}" --password-stdin + # The registry tags are applied here, right before the push, and removed again by the + # cleanup step (untag only). - name: Tag and push if: github.event_name == 'push' shell: bash run: | - for tag in ${{ steps.meta.outputs.extra_tags }}; do - docker tag "${{ steps.meta.outputs.sha_tag }}" "${tag}" + for tag in ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.extra_tags }}; do + docker tag "${{ steps.meta.outputs.build_tag }}" "${tag}" done for tag in ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.extra_tags }}; do docker push "${tag}" @@ -331,8 +343,10 @@ jobs: if [ -n "${DOCKER_CONFIG:-}" ]; then rm -rf "${DOCKER_CONFIG}" fi + # Registry tags first (untag only), then the per-run build tag. bash scripts/ci/cleanup_job_resources.sh \ - "${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }} + "${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }} \ + "${{ steps.meta.outputs.build_tag }}" # --------------------------------------------------------------------------- # 7. Docs drift check diff --git a/scripts/ci/cleanup_job_resources.sh b/scripts/ci/cleanup_job_resources.sh index 8dbd5c5..b868d5b 100755 --- a/scripts/ci/cleanup_job_resources.sh +++ b/scripts/ci/cleanup_job_resources.sh @@ -10,10 +10,12 @@ # network only), plus the containers still attached to those networks; # * containers started from the per-run image refs given as arguments, and # those refs. Per-run refs are names containing "aegis-ci-" (named after the -# run id) and immutable ":sha-" tags; -# * for any other ref (shared tags such as :latest, :main, :dev, which a -# deployment on the same daemon may use) only the tag is removed. Containers -# are never touched, and an image still used by a container simply stays. +# run id), such as the local build tag aegis-ci--build:; +# * for any other ref (registry tags such as :sha-<12>, :latest, :main, :dev, +# which a deployment on the same daemon may use) only the tag is removed. +# Containers are never touched, and an image still used by a container simply +# stays. Pass registry tags before the per-run ref: the image itself goes away +# with its last tag. # Everything is best effort (a failing removal never fails the job). set -uo pipefail @@ -46,7 +48,7 @@ fi is_per_run_ref() { case "$1" in - *aegis-ci-* | *:sha-*) return 0 ;; + *aegis-ci-*) return 0 ;; *) return 1 ;; esac } diff --git a/tests/security/test_ci_workflows.py b/tests/security/test_ci_workflows.py index a3a80c4..07a0d1c 100644 --- a/tests/security/test_ci_workflows.py +++ b/tests/security/test_ci_workflows.py @@ -275,19 +275,25 @@ def run_cleanup(tmp_path: Path, *refs: str) -> list[str]: def test_cleanup_removes_containers_only_for_per_run_refs(tmp_path: Path) -> None: registry = "git.hiddenden.cafe/hiddenden/aegisgitea-mcp" - per_run = f"{registry}:sha-0123456789ab" - shared = [f"{registry}:latest", f"{registry}:main", f"{registry}:dev"] + per_run = "aegis-ci-4242-build:0123456789abcdef" + shared = [ + f"{registry}:sha-0123456789ab", + f"{registry}:latest", + f"{registry}:main", + f"{registry}:dev", + ] - calls = run_cleanup(tmp_path, per_run, *shared) + calls = run_cleanup(tmp_path, *shared, per_run) removed = [call for call in calls if call.startswith("rm -f")] assert removed == [f"rm -f -v cid-of-{per_run}"] queried = [call for call in calls if call.startswith("ps ")] - assert all("sha-0123456789ab" in call for call in queried) and len(queried) == 1 - # Shared tags are only untagged. + assert len(queried) == 1 and per_run in queried[0] + # Registry tags (the immutable sha tag included) are only untagged. for ref in shared: assert f"image rm {ref}" in calls assert f"rmi {ref}" not in calls + assert f"rmi {per_run}" in calls def test_cleanup_treats_names_with_the_run_prefix_as_per_run(tmp_path: Path) -> None: @@ -324,3 +330,27 @@ def test_scripts_are_executable_and_parse() -> None: for script in (ROOT / "scripts" / "ci").glob("*.sh"): assert os.access(script, os.X_OK), script.name subprocess.run(["bash", "-n", str(script)], check=True, timeout=30) # noqa: S603, S607 + + +def test_build_and_push_builds_under_a_per_run_tag_and_gates_on_every_test_job() -> None: + jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")} + build = jobs["build-and-push"] + + assert {"lint", "test-unit", "test-integration", "test-mcp", "security"} <= set(build.needs) + text = build.text + assert 'BUILD_TAG="aegis-ci-${GITHUB_RUN_ID}-build:${GITHUB_SHA}"' in text + # Nothing is built, scanned or smoke-tested under a registry tag. + assert '-t "${{ steps.meta.outputs.build_tag }}"' in text + assert 'scan_image.sh "${{ steps.meta.outputs.build_tag }}"' in text + assert '-t "${{ steps.meta.outputs.sha_tag }}"' not in text + # Registry tags are applied only in the push step, and cleanup removes the build tag last. + assert text.index("docker build") < text.index("docker tag") < text.index("docker push") + assert text.count("docker tag") == 1 + cleanup = text[text.index("scripts/ci/cleanup_job_resources.sh") :] + assert cleanup.index("sha_tag") < cleanup.index("build_tag") + + +def test_the_integration_job_requires_the_actions_runner() -> None: + jobs = {job.name: job for job in jobs_of(ROOT / ".gitea" / "workflows" / "ci.yml")} + + assert 'AEGIS_IT_REQUIRE_RUNNER: "1"' in jobs["test-integration"].text