name: ci # One pipeline, eight jobs: # # gitleaks secret scan over the full history # lint ruff check, ruff format --check, mypy # 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, # 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 # # Jobs that use the shared Docker daemon or secrets (test-integration, test-mcp, # build-and-push) do not run for pull requests from forks: a fork controls the # workflow and test code, and access to the daemon is root-equivalent on the # host. Also enable "require approval for workflows from forks" in Gitea. # # Every third-party action is pinned to a full commit SHA (the version is in the # trailing comment); bump deliberately. # # 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. 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: branches: - '**' pull_request: branches: - dev - main env: UV_PYTHON_DOWNLOADS: never UV_NO_PROGRESS: "1" jobs: # --------------------------------------------------------------------------- # 1. Secret scan: pinned gitleaks binary (sha256 verified), full history. # --------------------------------------------------------------------------- gitleaks: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: fetch-depth: 0 persist-credentials: false - name: Install gitleaks shell: bash run: | bin_dir="${RUNNER_TEMP:-/tmp}/gitleaks-bin" bash scripts/ci/install_gitleaks.sh "${bin_dir}" echo "${bin_dir}" >> "$GITHUB_PATH" - name: Scan history shell: bash run: gitleaks git --config .gitleaks.toml --redact --no-banner --verbose --exit-code 1 . # --------------------------------------------------------------------------- # 2. Lint # --------------------------------------------------------------------------- lint: runs-on: ubuntu-latest timeout-minutes: 15 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false - name: Install dependencies run: uv sync --locked - name: Ruff check run: uv run ruff check . - name: Ruff format run: uv run ruff format --check . - name: Mypy run: uv run mypy src # --------------------------------------------------------------------------- # 3. Unit tests (integration tests are skipped without AEGIS_INTEGRATION=1). # --------------------------------------------------------------------------- test-unit: runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false - name: Install dependencies run: uv sync --locked - name: Run unit tests run: uv run pytest --cov-report=term-missing --cov-report=xml:coverage.xml - name: Upload coverage report # Best effort: some act_runner versions do not support the v4 artifact # backend, and the coverage gate itself already ran above. continue-on-error: true uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: coverage-${{ github.run_id }} path: coverage.xml # --------------------------------------------------------------------------- # 4. Integration tests against a throwaway Gitea 28.1.0 in Docker. The job # container is joined to a per-run network (container mode), see # tests/integration/README.md. The harness removes what it creates; the # cleanup step is the safety net for cancelled runs. # --------------------------------------------------------------------------- test-integration: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 40 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false - name: Install dependencies run: uv sync --locked - name: Run integration tests 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() shell: bash run: bash scripts/ci/cleanup_job_resources.sh # --------------------------------------------------------------------------- # 5. MCP smoke tests. Builds its own copy of the image (same Dockerfile, same # commit) so the tests can also exercise the container, and passes the # reference through AEGIS_MCP_IMAGE. The image cannot be shared with # build-and-push: pull requests never push, and jobs may land on different # runners. # --------------------------------------------------------------------------- test-mcp: if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest timeout-minutes: 40 env: MCP_IMAGE: aegis-ci-${{ github.run_id }}-mcp:test steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false - name: Install dependencies run: uv sync --locked - name: Build image run: docker build -f docker/Dockerfile -t "${MCP_IMAGE}" . - name: Run MCP smoke tests env: AEGIS_MCP_IMAGE: ${{ env.MCP_IMAGE }} # Smoke tests that start a throwaway Gitea use the same harness as the # integration tests. AEGIS_INTEGRATION: "1" AEGIS_IT_NETWORK_MODE: container run: uv run pytest tests/mcp -m mcp_smoke --no-cov - name: Cleanup if: always() shell: bash run: bash scripts/ci/cleanup_job_resources.sh "${MCP_IMAGE}" # --------------------------------------------------------------------------- # 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 # :latest and :main additionally on main # Pull requests only build. The pushed digest is recorded in the job # outputs and the job summary. # --------------------------------------------------------------------------- build-and-push: 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 outputs: image: ${{ steps.meta.outputs.sha_tag }} digest: ${{ steps.digest.outputs.digest }} steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Compute image name and tags id: meta shell: bash 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 EXTRA_TAGS="${IMAGE}:latest ${IMAGE}:main" elif [ "${GITHUB_REF_NAME}" = "dev" ]; then EXTRA_TAGS="${IMAGE}:dev" fi fi { echo "image=${IMAGE}" echo "sha_tag=${SHA_TAG}" echo "build_tag=${BUILD_TAG}" echo "extra_tags=${EXTRA_TAGS}" } >> "$GITHUB_OUTPUT" - name: Build image shell: bash run: | 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.build_tag }}" . - name: Smoke-test image shell: bash 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 # in security/trivyignore.txt and docs/security.md. - name: Install Trivy shell: bash run: | bin_dir="${RUNNER_TEMP:-/tmp}/trivy-bin" bash scripts/ci/install_trivy.sh "${bin_dir}" echo "${bin_dir}" >> "$GITHUB_PATH" - name: Scan image shell: bash 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 # credential store of the runner's Docker client. - name: Log in to the registry if: github.event_name == 'push' shell: bash env: # PAT with write:package scope. The built-in token lacks package-write # on this instance. REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_USER: ${{ github.actor }} run: | config_dir="$(mktemp -d)" echo "DOCKER_CONFIG=${config_dir}" >> "$GITHUB_ENV" 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.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}" done - name: Record digest id: digest shell: bash run: | if [ "${GITHUB_EVENT_NAME}" = "push" ]; then DIGEST="$(docker inspect --format '{{index .RepoDigests 0}}' "${{ steps.meta.outputs.sha_tag }}")" else DIGEST="not pushed (pull request build)" fi echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT" { echo "### Image" echo echo "- tag: \`${{ steps.meta.outputs.sha_tag }}\`" echo "- digest: \`${DIGEST}\`" if [ -n "${{ steps.meta.outputs.extra_tags }}" ]; then echo "- also tagged: \`${{ steps.meta.outputs.extra_tags }}\`" fi } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" echo "image digest: ${DIGEST}" # Removes exactly what this run created, even on failure. No prune. - name: Cleanup if: always() shell: bash run: | # Only the per-job credential directory created above is removed. 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.build_tag }}" # --------------------------------------------------------------------------- # 7. Docs drift check # --------------------------------------------------------------------------- docs-check: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false - name: Install dependencies run: uv sync --locked - name: Check docs against code and spec run: uv run python scripts/check_docs.py # --------------------------------------------------------------------------- # 8. Security: test suite, dependency audit, bandit rules via ruff. # Waivers live in docs/security.md; advisory ignores in # security/pip-audit-ignore.txt. # --------------------------------------------------------------------------- security: runs-on: ubuntu-latest timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 with: persist-credentials: false - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5.4.2 with: version: "0.12.5" enable-cache: false - name: Install dependencies run: uv sync --locked - name: Security tests run: uv run pytest tests/security --no-cov - name: Dependency audit run: bash scripts/ci/pip_audit.sh - name: Ruff security rules run: uv run ruff check --select S .