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) # test-integration pytest against a throwaway Gitea in Docker # test-mcp MCP smoke tests, including against the freshly built image # build-and-push build the image; push it on push events (needs lint + test-unit) # docs-check docs drift check # security security test suite, pip-audit, ruff bandit rules # # 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. 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@v4 with: fetch-depth: 0 - 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@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@v5 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@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@v5 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@v4 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: runs-on: ubuntu-latest timeout-minutes: 40 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@v5 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 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: runs-on: ubuntu-latest timeout-minutes: 40 env: MCP_IMAGE: aegis-ci-${{ github.run_id }}-mcp:test steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@v5 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. On push events it is also # 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] 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@v4 - name: Compute image name and tags id: meta shell: bash run: | IMAGE="git.hiddenden.cafe/${GITHUB_REPOSITORY,,}" SHA_TAG="${IMAGE}:sha-${GITHUB_SHA::12}" 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 "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.sha_tag }}" . - name: Smoke-test image shell: bash run: docker run --rm "${{ steps.meta.outputs.sha_tag }}" version - name: Log in to the registry if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: git.hiddenden.cafe username: ${{ github.actor }} # PAT with write:package scope. The built-in token lacks package-write # on this instance. password: ${{ secrets.REGISTRY_TOKEN }} - 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}" 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: | docker logout git.hiddenden.cafe || true bash scripts/ci/cleanup_job_resources.sh \ "${{ steps.meta.outputs.sha_tag }}" ${{ steps.meta.outputs.extra_tags }} # --------------------------------------------------------------------------- # 7. Docs drift check # --------------------------------------------------------------------------- docs-check: runs-on: ubuntu-latest timeout-minutes: 10 steps: - name: Checkout uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@v5 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@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Set up uv uses: astral-sh/setup-uv@v5 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 .