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-unit pytest (coverage gate from pyproject.toml: 90 percent)
|
||||||
# test-integration pytest against a throwaway Gitea in Docker
|
# test-integration pytest against a throwaway Gitea in Docker
|
||||||
# test-mcp MCP smoke tests, including against the freshly built image
|
# 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
|
# docs-check docs drift check
|
||||||
# security security test suite, pip-audit, ruff bandit rules
|
# 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
|
# 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()`
|
# 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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -153,6 +158,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
AEGIS_INTEGRATION: "1"
|
AEGIS_INTEGRATION: "1"
|
||||||
AEGIS_IT_NETWORK_MODE: container
|
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
|
run: uv run pytest tests/integration -m integration --no-cov
|
||||||
- name: Cleanup
|
- name: Cleanup
|
||||||
if: always()
|
if: always()
|
||||||
@@ -204,8 +211,8 @@ jobs:
|
|||||||
run: bash scripts/ci/cleanup_job_resources.sh "${MCP_IMAGE}"
|
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
|
# 6. Build the image from the checked-out commit under a per-run local tag
|
||||||
# pushed:
|
# (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
|
# :sha-<12> every branch (immutable, traceable to the commit; lets any
|
||||||
# feature branch image be pulled and tested)
|
# feature branch image be pulled and tested)
|
||||||
# :dev additionally on dev
|
# :dev additionally on dev
|
||||||
@@ -214,7 +221,7 @@ jobs:
|
|||||||
# outputs and the job summary.
|
# outputs and the job summary.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
build-and-push:
|
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
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
@@ -233,6 +240,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
|
IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}"
|
||||||
SHA_TAG="${IMAGE}:sha-${GITHUB_SHA::12}"
|
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=""
|
EXTRA_TAGS=""
|
||||||
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
|
if [ "${GITHUB_EVENT_NAME}" = "push" ]; then
|
||||||
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
if [ "${GITHUB_REF_NAME}" = "main" ]; then
|
||||||
@@ -244,6 +253,7 @@ jobs:
|
|||||||
{
|
{
|
||||||
echo "image=${IMAGE}"
|
echo "image=${IMAGE}"
|
||||||
echo "sha_tag=${SHA_TAG}"
|
echo "sha_tag=${SHA_TAG}"
|
||||||
|
echo "build_tag=${BUILD_TAG}"
|
||||||
echo "extra_tags=${EXTRA_TAGS}"
|
echo "extra_tags=${EXTRA_TAGS}"
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -253,11 +263,11 @@ jobs:
|
|||||||
docker build -f docker/Dockerfile \
|
docker build -f docker/Dockerfile \
|
||||||
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
--label "org.opencontainers.image.revision=${GITHUB_SHA}" \
|
||||||
--label "org.opencontainers.image.source=https://git.hiddenden.cafe/${GITHUB_REPOSITORY}" \
|
--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
|
- name: Smoke-test image
|
||||||
shell: bash
|
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
|
# Vulnerability scan of the image that is about to be pushed: pinned Trivy
|
||||||
# (sha256 verified), fails on fixable HIGH and CRITICAL findings. Waivers live
|
# (sha256 verified), fails on fixable HIGH and CRITICAL findings. Waivers live
|
||||||
@@ -271,7 +281,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Scan image
|
- name: Scan image
|
||||||
shell: bash
|
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
|
# 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
|
# 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}" \
|
printf '%s' "${REGISTRY_TOKEN}" | DOCKER_CONFIG="${config_dir}" \
|
||||||
docker login git.hiddenden.cafe --username "${REGISTRY_USER}" --password-stdin
|
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
|
- name: Tag and push
|
||||||
if: github.event_name == 'push'
|
if: github.event_name == 'push'
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
for tag in ${{ steps.meta.outputs.extra_tags }}; do
|
for tag in ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.extra_tags }}; do
|
||||||
docker tag "${{ steps.meta.outputs.sha_tag }}" "${tag}"
|
docker tag "${{ steps.meta.outputs.build_tag }}" "${tag}"
|
||||||
done
|
done
|
||||||
for tag in ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.extra_tags }}; do
|
for tag in ${{ steps.meta.outputs.sha_tag }} ${{ steps.meta.outputs.extra_tags }}; do
|
||||||
docker push "${tag}"
|
docker push "${tag}"
|
||||||
@@ -331,8 +343,10 @@ jobs:
|
|||||||
if [ -n "${DOCKER_CONFIG:-}" ]; then
|
if [ -n "${DOCKER_CONFIG:-}" ]; then
|
||||||
rm -rf "${DOCKER_CONFIG}"
|
rm -rf "${DOCKER_CONFIG}"
|
||||||
fi
|
fi
|
||||||
|
# Registry tags first (untag only), then the per-run build tag.
|
||||||
bash scripts/ci/cleanup_job_resources.sh \
|
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
|
# 7. Docs drift check
|
||||||
|
|||||||
@@ -10,10 +10,12 @@
|
|||||||
# network only), plus the containers still attached to those networks;
|
# network only), plus the containers still attached to those networks;
|
||||||
# * containers started from the per-run image refs given as arguments, and
|
# * 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
|
# those refs. Per-run refs are names containing "aegis-ci-" (named after the
|
||||||
# run id) and immutable ":sha-<commit>" tags;
|
# run id), such as the local build tag aegis-ci-<run id>-build:<sha>;
|
||||||
# * for any other ref (shared tags such as :latest, :main, :dev, which a
|
# * for any other ref (registry tags such as :sha-<12>, :latest, :main, :dev,
|
||||||
# deployment on the same daemon may use) only the tag is removed. Containers
|
# which a deployment on the same daemon may use) only the tag is removed.
|
||||||
# are never touched, and an image still used by a container simply stays.
|
# 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).
|
# Everything is best effort (a failing removal never fails the job).
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
@@ -46,7 +48,7 @@ fi
|
|||||||
|
|
||||||
is_per_run_ref() {
|
is_per_run_ref() {
|
||||||
case "$1" in
|
case "$1" in
|
||||||
*aegis-ci-* | *:sha-*) return 0 ;;
|
*aegis-ci-*) return 0 ;;
|
||||||
*) return 1 ;;
|
*) return 1 ;;
|
||||||
esac
|
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:
|
def test_cleanup_removes_containers_only_for_per_run_refs(tmp_path: Path) -> None:
|
||||||
registry = "git.hiddenden.cafe/hiddenden/aegisgitea-mcp"
|
registry = "git.hiddenden.cafe/hiddenden/aegisgitea-mcp"
|
||||||
per_run = f"{registry}:sha-0123456789ab"
|
per_run = "aegis-ci-4242-build:0123456789abcdef"
|
||||||
shared = [f"{registry}:latest", f"{registry}:main", f"{registry}:dev"]
|
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")]
|
removed = [call for call in calls if call.startswith("rm -f")]
|
||||||
assert removed == [f"rm -f -v cid-of-{per_run}"]
|
assert removed == [f"rm -f -v cid-of-{per_run}"]
|
||||||
queried = [call for call in calls if call.startswith("ps ")]
|
queried = [call for call in calls if call.startswith("ps ")]
|
||||||
assert all("sha-0123456789ab" in call for call in queried) and len(queried) == 1
|
assert len(queried) == 1 and per_run in queried[0]
|
||||||
# Shared tags are only untagged.
|
# Registry tags (the immutable sha tag included) are only untagged.
|
||||||
for ref in shared:
|
for ref in shared:
|
||||||
assert f"image rm {ref}" in calls
|
assert f"image rm {ref}" in calls
|
||||||
assert f"rmi {ref}" not 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:
|
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"):
|
for script in (ROOT / "scripts" / "ci").glob("*.sh"):
|
||||||
assert os.access(script, os.X_OK), script.name
|
assert os.access(script, os.X_OK), script.name
|
||||||
subprocess.run(["bash", "-n", str(script)], check=True, timeout=30) # noqa: S603, S607
|
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