ci: build under a per-run local tag, untag registry refs only, gate the image push on every test job
This commit is contained in:
1 parent
8ff67f5859
commit
4e108771f8
3 files changed
+67
-21
No files matched your search
+25
-11
@@ -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-<run id>-...) 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-<run id>-build:<sha>). 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
|
||||
|
||||
@@ -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-<commit>" 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-<run id>-build:<sha>;
|
||||
# * 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user