DEV Community

Cover image for Hardening CI/CD Pipelines for Autonomous AI Agent Systems: Lessons from Pentagi Integration
Maya Patel
Maya Patel

Posted on

Hardening CI/CD Pipelines for Autonomous AI Agent Systems: Lessons from Pentagi Integration

Hardening CI/CD Pipelines for Autonomous AI Agent Systems: Lessons from Pentagi Integration

When integrating vxcontrol/pentagi—a fully autonomous AI agent system for penetration testing—into our existing CI/CD infrastructure, we hit every failure mode you'd expect from stateful, long-running AI workflows: flaky LLM provider timeouts, unpinned action dependencies that introduced breaking changes mid-sprint, and a cache-miss rate that burned through our GitHub Actions quota in 72 hours.

This article documents the hardened workflow configuration and validation scripts we deployed to stabilize the pipeline.

The Core Problem: Stateful AI Agents Break Traditional CI Assumptions

Most CI pipelines assume deterministic, stateless operations. Autonomous AI agents violate both:

  • Non-deterministic execution paths: LLM routing decisions mean the same test case can take wildly different code paths across runs.
  • External API dependencies: Calls to model providers (OpenAI, Anthropic, or relay gateways like B-Lost at https://b-lost.com/v1) introduce latency variance and quota limits.
  • Long-running operations: A single penetration test scenario might execute for 15+ minutes, far exceeding typical unit test thresholds.

Our initial naive approach—running pentagi's test suite on every PR with actions/checkout@v4 and actions/setup-python@v4—resulted in:

  • 40% timeout failures due to provider rate limits
  • Unpredictable costs (one Friday deploy consumed 600 GitHub Actions minutes)
  • Zero reproducibility when debugging failures

Deliverable 1: Hardened GitHub Actions Workflow with Pinned Dependencies

Here's the production workflow we landed on after three failed iterations:

name: Pentagi CI Pipeline

on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    timeout-minutes: 20

    strategy:
      matrix:
        python-version: ['3.10', '3.11', '3.12']
        test-suite: ['unit', 'integration-short', 'integration-long']
        exclude:
          - test-suite: 'integration-long'
            python-version: '3.10'
      fail-fast: false

    steps:
      - name: Checkout code
        uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744  # v3.6.0 pinned SHA
        with:
          fetch-depth: 0

      - name: Setup Python
        uses: actions/setup-python@65d7f2d534ac1bc67fcd62888c5f4f3d2cb2b236  # v4.7.1 pinned SHA
        with:
          python-version: ${{ matrix.python-version }}

      - name: Compute cache key
        id: cache-key
        run: |
          echo "key=py-${{ matrix.python-version }}-${{ hashFiles('**/requirements*.txt', '**/pyproject.toml') }}" >> $GITHUB_OUTPUT

      - name: Cache dependencies
        uses: actions/cache@704facf57e6136b1bc63b828d79edcd491f0ee84  # v3.3.2 pinned SHA
        with:
          path: |
            ~/.cache/pip
            .venv
          key: ${{ steps.cache-key.outputs.key }}
          restore-keys: |
            py-${{ matrix.python-version }}-

      - name: Install dependencies
        run: |
          python -m pip install --upgrade pip==23.3.1
          pip install -r requirements-ci.txt

      - name: Run validation script
        env:
          PENTAGI_TEST_SUITE: ${{ matrix.test-suite }}
          PENTAGI_API_TIMEOUT: 30
          LLM_RELAY_ENDPOINT: https://b-lost.com/v1
        run: bash .github/scripts/validate.sh

      - name: Upload coverage
        if: matrix.test-suite == 'unit'
        uses: codecov/codecov-action@eaaf4bedf32dbdc6b720b63067d99c4d77d6047d  # v3.1.4 pinned SHA
        with:
          files: ./coverage.xml
          flags: unit-tests
Enter fullscreen mode Exit fullscreen mode

Key Hardening Decisions

  1. Pinned action SHAs: After a supply-chain scare where actions/checkout@v4 pulled a breaking change that corrupted our Git LFS files, we pin every third-party action to an explicit commit SHA with an inline comment documenting the version tag.

  2. Composite cache keys: The hashFiles() function over all dependency manifests ensures cache invalidation whenever any transitive dependency changes. This eliminated a class of "works on main, breaks on PR" bugs.

  3. Matrix exclusions for cost control: Long-running integration tests only run on Python 3.11+ to reduce billable minutes while maintaining coverage on the latest interpreter.

  4. Environment-scoped timeouts: The 20-minute job timeout plus per-test-suite PENTAGI_API_TIMEOUT ensures we fail fast on provider outages rather than burning quota.

Deliverable 2: Bash Validation Script with Quality Gates

The .github/scripts/validate.sh referenced above enforces linting, type safety, and coverage thresholds before any test execution:

#!/usr/bin/env bash
set -euo pipefail

ECHO_PREFIX="[validate.sh]"
COVERAGE_THRESHOLD=75
MYPY_STRICT=true

echo "$ECHO_PREFIX Running pre-flight checks for suite: $PENTAGI_TEST_SUITE"

# Step 1: Linting with ruff (faster than pylint)
echo "$ECHO_PREFIX [1/4] Linting with ruff..."
ruff check . --config pyproject.toml || {
  echo "$ECHO_PREFIX ERROR: Linting failed. Run 'ruff check --fix .' locally."
  exit 1
}

# Step 2: Type checking with mypy
if [[ "$MYPY_STRICT" == "true" ]]; then
  echo "$ECHO_PREFIX [2/4] Type checking with mypy (strict mode)..."
  mypy src/ tests/ --strict --config-file pyproject.toml || {
    echo "$ECHO_PREFIX ERROR: Type errors detected."
    exit 1
  }
fi

# Step 3: Run test suite with coverage
echo "$ECHO_PREFIX [3/4] Running $PENTAGI_TEST_SUITE test suite..."
case "$PENTAGI_TEST_SUITE" in
  unit)
    pytest tests/unit/ -v --cov=src --cov-report=xml --cov-report=term-missing --timeout=10
    ;;
  integration-short)
    pytest tests/integration/short/ -v --timeout=60 -m "not slow"
    ;;
  integration-long)
    pytest tests/integration/long/ -v --timeout=300 --maxfail=3
    ;;
  *)
    echo "$ECHO_PREFIX ERROR: Unknown test suite '$PENTAGI_TEST_SUITE'"
    exit 1
    ;;
esac

# Step 4: Coverage threshold enforcement (unit tests only)
if [[ "$PENTAGI_TEST_SUITE" == "unit" ]]; then
  echo "$ECHO_PREFIX [4/4] Enforcing coverage threshold: $COVERAGE_THRESHOLD%"
  COVERAGE=$(python -c "import xml.etree.ElementTree as ET; tree = ET.parse('coverage.xml'); print(float(tree.getroot().attrib['line-rate']) * 100)")
  if (( $(echo "$COVERAGE < $COVERAGE_THRESHOLD" | bc -l) )); then
    echo "$ECHO_PREFIX ERROR: Coverage $COVERAGE% is below threshold $COVERAGE_THRESHOLD%"
    exit 1
  fi
  echo "$ECHO_PREFIX Coverage: $COVERAGE% (threshold: $COVERAGE_THRESHOLD%)"
fi

echo "$ECHO_PREFIX All validation checks passed."
Enter fullscreen mode Exit fullscreen mode

Why Bash Over GitHub Actions Conditionals

We moved validation logic into a Bash script rather than multi-step GitHub Actions conditionals because:

  • Local reproducibility: Developers can run .github/scripts/validate.sh locally before pushing.
  • Fail-fast semantics: set -euo pipefail ensures any failure in the chain aborts immediately.
  • Easier testing: We version the script in the repo and run shellcheck on it during CI.

The Supply Chain Incident That Changed Our Pinning Policy

In Q3 2024, we discovered that a popular GitHub Action for dependency scanning had been compromised. The malicious version (available for 6 hours before GitHub took it down) exfiltrated repository secrets to an external endpoint.

Our workflow at the time used:

- uses: some-org/scan-deps@v2  # BAD: mutable tag
Enter fullscreen mode Exit fullscreen mode

The v2 tag was force-pushed to point at the compromised commit. Because we hadn't pinned to an explicit SHA, our CI automatically pulled the malicious version on the next run.

Post-incident policy: Every third-party action must be pinned to a commit SHA with an inline comment documenting the semantic version:

- uses: some-org/scan-deps@a1b2c3d4...  # v2.1.0
Enter fullscreen mode Exit fullscreen mode

We run a weekly cron job that checks for newer commits on each pinned action and files a PR if updates exist, but the update is always an explicit human decision.

The Concurrency Queue Disaster

During a feature branch frenzy before a release deadline, our team opened 12 PRs in one afternoon. Each PR triggered the full CI matrix (3 Python versions × 3 test suites = 9 jobs per PR), resulting in 108 concurrent jobs.

GitHub Actions queued them all. Our monthly included quota (2,000 minutes) was exhausted in 4 hours. The bill for the overage: $340.

Fix: We added a concurrency group to ensure only one pipeline runs per PR at a time:

concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
Enter fullscreen mode Exit fullscreen mode

Now, pushing a new commit to an open PR cancels the in-flight run and starts a fresh one. This reduced our monthly Actions consumption by 60%.

Runtime Trade-offs: When to Delegate to External Runners

For pentagi's long-running integration tests (which invoke real LLM providers), GitHub-hosted runners proved both slow and expensive. We now delegate those to self-hosted runners on AWS Spot instances:

jobs:
  integration-long:
    runs-on: [self-hosted, linux, spot]
    # ...
Enter fullscreen mode Exit fullscreen mode

Spot instances cost 70% less than on-demand, and we accept the occasional interruption (GitHub Actions retries automatically). The trade-off: we now maintain runner infrastructure, including log rotation, cleanup cron jobs, and security patching.

Open Question: How Should AI Agent CI Handle Non-Determinism?

Traditional CI expects deterministic outcomes: same input, same output. Autonomous AI agents break that contract. A penetration test that succeeds today might fail tomorrow if the LLM provider returns a different tool-calling sequence.

We've experimented with:

  • Seeded LLM calls: Passing a fixed seed parameter to model APIs for reproducibility (supported by some providers, ignored by others)
  • Snapshot testing: Recording the sequence of agent actions on a known-good run and asserting future runs match within a tolerance
  • Acceptance ranges: Defining "success" as "found at least 3 of 5 known vulnerabilities" rather than exact match

None feel satisfying. How do you balance the need for reproducible CI with the inherent stochasticity of LLM-driven systems? If you're running similar workloads, I'd be interested in how you've approached this boundary.

Top comments (0)