DEV Community

Cover image for Bounding CI Cost Without Weakening Trust in Keploy Pull Requests
Maya Patel
Maya Patel

Posted on

Bounding CI Cost Without Weakening Trust in Keploy Pull Requests

Bounding CI Cost Without Weakening Trust in Keploy Pull Requests

It's 2:47 AM and your GitHub Actions budget notification fires: 90% consumed with eleven days left in the billing cycle. You check the console and find forty-three overlapping CI runs—someone pushed a quick typo fix eight times in fifteen minutes, and every push triggered a full matrix build with no cancellation policy. Each job compiles Go packages, runs the test suite, exercises privileged recording infrastructure, and uploads artifacts. None of them will finish before the next push arrives and starts the cycle again.

That incident reveals two independent failures: there was no concept of superseded work, and different trust boundaries were collapsed into a single expensive job. Fixing only one leaves the other intact. For keploy/keploy adoption, where production-sandbox testing involves compiling Go, testing application behavior, and exercising privileged recording or replay infrastructure, those boundaries matter. Putting them in one oversized workflow makes cancellation expensive and failures difficult to interpret.

The configuration below is an adoption baseline—not a claim about Keploy's current workflows or measured coverage. It assumes a root Go module and requires validation against the repository's actual toolchain and test prerequisites before becoming a required check.

The Failure Is Usually Multiplication

Consider a representative scenario: a maintainer adds a third-party reporting action by mutable tag. Later, that tag resolves to compromised code. Meanwhile, contributors repeatedly push fixes, creating overlapping matrix runs with no concurrency policy.

These failures compound. The compromised action executes wherever it is referenced; redundant runs multiply both exposure and runner consumption. A workflow timeout limits each job, but it does not stop twenty obsolete jobs from starting.

The root causes are independent: dependency identity was mutable, and scheduling had no concept of superseded work. For Keploy adoption, I separate the architecture into three layers: an unprivileged pull-request gate, isolated Linux integration execution, and trusted publishing. Only the first belongs below. Recording, replay, privileged networking, production-derived artifacts, and release credentials stay outside this gate.

One Workflow, With an Embedded Validation Script

Save this as .github/workflows/pr-go.yml. The single configuration creates a Bash script so local validation and CI policy have an explicit, auditable implementation. Once adopted, move that script into a tracked file without changing its behavior.

name: PR Go gate

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

concurrency:
  group: go-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true

jobs:
  validate:
    name: Go / ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    timeout-minutes: 20
    strategy:
      fail-fast: true
      max-parallel: 2
      matrix:
        os: [ubuntu-22.04, ubuntu-24.04]
    env:
      MIN_COVERAGE: "60"

    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          persist-credentials: false

      - uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5
        with:
          go-version-file: go.mod
          cache: false

      - name: Locate Go caches
        id: paths
        shell: bash
        run: |
          echo "mod=$(go env GOMODCACHE)" >> "$GITHUB_OUTPUT"
          echo "build=$(go env GOCACHE)" >> "$GITHUB_OUTPUT"
          echo "version=$(go env GOVERSION)" >> "$GITHUB_OUTPUT"

      - uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
        with:
          path: |
            ${{ steps.paths.outputs.mod }}
            ${{ steps.paths.outputs.build }}
          key: go-${{ runner.os }}-${{ runner.arch }}-${{ matrix.os }}-${{ steps.paths.outputs.version }}-${{ hashFiles('go.sum') }}

      - name: Validate source
        shell: bash
        run: |
          cat > "$RUNNER_TEMP/validate.sh" <<'BASH'
          #!/usr/bin/env bash
          set -euo pipefail
          mapfile -d '' files < <(git ls-files -z '*.go')
          if ((${#files[@]})); then
            unformatted="$(gofmt -l "${files[@]}")"
            if [[ -n "$unformatted" ]]; then
              printf 'Unformatted Go files:\n%s\n' "$unformatted"
              exit 1
            fi
          fi
          go mod download
          go mod verify
          go vet ./...
          go build ./...
          go test -race -count=1 -timeout=8m \
            -covermode=atomic -coverprofile=coverage.out ./...
          actual="$(go tool cover -func=coverage.out |
            awk '$1 == "total:" {gsub(/%/, "", $3); print $3}')"
          awk -v actual="$actual" -v minimum="$MIN_COVERAGE" '
            BEGIN {
              printf "Coverage: %s%%; required: %s%%\n", actual, minimum
              exit !(actual != "" && actual + 0 >= minimum + 0)
            }'
          BASH
          bash "$RUNNER_TEMP/validate.sh"
Enter fullscreen mode Exit fullscreen mode

What This Boundary Actually Guarantees

The matrix checks two Linux environments without inventing an unsupported Go-version range; go.mod supplies the toolchain request. Confirm that both runner images support the repository's required native libraries. Go's toolchain selection can still download a newer toolchain requested by the module.

The script enforces formatting, static analysis, compilation with type checking, race-enabled tests, and a coverage floor. Sixty percent is an example policy, not Keploy's reported coverage. Measure the baseline before enabling branch protection. Package-default coverage also does not prove that replay paths or failure recovery received meaningful exercise.

Tests requiring privileges or external services will fail here. Classify those tests explicitly using the repository's supported package selection or build tags; do not quietly replace the command with a narrower suite and retain a misleading "full validation" label.

The Trade-Offs Worth Keeping

Exact action SHAs prevent a moving tag from silently changing execution. They do not certify the pinned code or freeze every dependency it downloads. Review updates, retain version comments for maintainability, and audit transitive execution separately.

The cache key includes the operating system, architecture, runner image, Go version, and dependency checksum file. Omitting broad restore prefixes sacrifices some cache hits for simpler provenance. Cache entries accelerate work; checksum verification and successful validation remain necessary.

Concurrency cancellation keeps only the newest run for a pull request. max-parallel bounds this matrix, not the entire repository's runner consumption. Separate pull requests still run concurrently, and cancellation is not instantaneous. Organization-level budgets and runner limits remain necessary.

Finally, keep publishing out of this workflow. Read-only permissions and disabled persisted Git credentials reduce exposure, but pull-request tests still execute contributor-controlled code. Avoid pull_request_target for that execution path, and never assume a self-hosted runner is safe simply because the token is read-only.

For Keploy, this boundary preserves a useful distinction: cheap source feedback can be aggressive and disposable; privileged integration evidence must be isolated and deliberate.

The Hardest Trade-Off

The configuration above makes one deliberate sacrifice: it prefers cache simplicity over hit rate. A more aggressive restore strategy could reuse module caches across Go version bumps or checksum updates, improving cold-start latency at the cost of provenance clarity. That trade-off is testable—measure your cache hit rate and typical download overhead, then decide whether the complexity is worth the seconds saved.

What's your tolerance for cache complexity versus build speed? Have you seen a setup where aggressive fallback keys caused more confusion than they prevented, or the opposite—where conservative caching created unnecessary pain during dependency churn?

Top comments (0)