Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
📖 0%
19 Oct 2026 · 22 min read ·Article 69 / 208
Go

Branch Protection AI Quality Gate for Golang: A Complete Setup for Go Projects

Set up branch protection with an AI quality gate for a Go project. Required status checks, aggregate scoring, CODEOWNERS, a merge queue, and a sustainable enforcement strategy.

IH
Ihsan Arif
Writer at Santekno · Backend Engineer

Branch Protection with an AI Quality Gate

Branch protection AI quality gate for golang is the key that turns every quality gate from a mere suggestion into a hard requirement that can’t be bypassed. In the previous articles, we built four quality gates: AI code review, security audit, coverage enforcement, and architecture linting. Now the question is: how do we make all of these mandatory?

The answer is GitHub Branch Protection with required status checks. This article is the finale of the quality-gate series, combining all the gates into one coherent system.


09.1 Branch Protection: The Basic Concept

Before configuring anything, we need to know what can be protected in GitHub. The list below summarizes the branch protection options and the status checks we’ve gathered from the previous articles.

text
 1Branch protection rules in GitHub:
 2  Settings → Branches → Add rule
 3
 4What can be protected:
 5  ✅ Require pull requests (no direct push to main)
 6  ✅ Require status checks (the pipeline must pass)
 7  ✅ Require signed commits
 8  ✅ Require linear history
 9  ✅ Require conversation resolution
10  ✅ Restrict who can push
11
12The most important one for us:
13  "Require status checks to pass before merging"
14  → This is what makes all the pipeline gates mandatory
15
16Status checks we have from Part 1-2:
17  ✅ L1: Go Build, Test & Lint
18  ✅ L2: Architecture Boundary Check
19  ✅ L3: AI Semantic Review (Copilot)
20  ✅ L4: Spec Compliance Audit
21  ✅ Security: Secret Detection
22  ✅ Security: Static Analysis (gosec)
23  ✅ Security: Dependency Vuln (govulncheck)
24  ✅ Coverage: Per-layer thresholds
25  ✅ Architecture: depguard + custom script

Of all the options, “Require status checks to pass before merging” is the most important — this is the mechanism that turns our pipeline gates into a mandatory condition before merge.


09.2 Aggregate Quality Score

Having nine separate status checks can be confusing. We need one aggregate score that combines all the gates into a single verdict. The Go program below computes a weighted score and decides PASS, WARNING, or FAIL.

go
  1// cmd/quality-gate/main.go
  2// Aggregate all quality scores into one final verdict
  3
  4package main
  5
  6import (
  7    "encoding/json"
  8    "fmt"
  9    "os"
 10)
 11
 12type QualityResult struct {
 13    Gate    string  `json:"gate"`
 14    Score   int     `json:"score"`   // 0-100
 15    Weight  float64 `json:"weight"`  // relative importance
 16    Pass    bool    `json:"pass"`
 17    Details string  `json:"details,omitempty"`
 18}
 19
 20type AggregateResult struct {
 21    Gates        []QualityResult `json:"gates"`
 22    WeightedScore int            `json:"weighted_score"`
 23    AllCritical  bool            `json:"all_critical_pass"`
 24    Verdict      string          `json:"verdict"` // PASS|WARNING|FAIL
 25}
 26
 27func main() {
 28    // Load individual gate results (from CI artifacts or env vars)
 29    gates := []QualityResult{
 30        {
 31            Gate:   "code_review",
 32            Score:  getEnvInt("AI_REVIEW_SCORE", 85),
 33            Weight: 0.30, // 30% weight
 34            Pass:   getEnvInt("AI_REVIEW_SCORE", 85) >= 60,
 35        },
 36        {
 37            Gate:   "security",
 38            Score:  getEnvInt("SECURITY_SCORE", 90),
 39            Weight: 0.25, // 25% weight — security is important
 40            Pass:   getEnvInt("SECURITY_SCORE", 90) >= 70,
 41        },
 42        {
 43            Gate:   "test_coverage",
 44            Score:  getEnvInt("COVERAGE_SCORE", 82),
 45            Weight: 0.25, // 25% weight
 46            Pass:   getEnvInt("COVERAGE_SCORE", 82) >= 75,
 47        },
 48        {
 49            Gate:   "architecture",
 50            Score:  getEnvInt("ARCH_SCORE", 95),
 51            Weight: 0.20, // 20% weight
 52            Pass:   getEnvInt("ARCH_SCORE", 95) >= 80,
 53        },
 54    }
 55
 56    // Calculate weighted score
 57    totalWeight := 0.0
 58    weightedSum := 0.0
 59    allCriticalPass := true
 60
 61    for _, g := range gates {
 62        weightedSum += float64(g.Score) * g.Weight
 63        totalWeight += g.Weight
 64        if !g.Pass {
 65            allCriticalPass = false
 66        }
 67    }
 68
 69    weightedScore := int(weightedSum / totalWeight)
 70
 71    // Determine verdict
 72    verdict := "PASS"
 73    if !allCriticalPass {
 74        verdict = "FAIL"
 75    } else if weightedScore < 75 {
 76        verdict = "WARNING"
 77    }
 78
 79    result := AggregateResult{
 80        Gates:         gates,
 81        WeightedScore: weightedScore,
 82        AllCritical:   allCriticalPass,
 83        Verdict:       verdict,
 84    }
 85
 86    // Output
 87    jsonOut, _ := json.MarshalIndent(result, "", "  ")
 88    fmt.Println(string(jsonOut))
 89
 90    // Print summary
 91    fmt.Printf("\n=== Quality Gate Summary ===\n")
 92    for _, g := range gates {
 93        status := "PASS"
 94        if !g.Pass {
 95            status = "FAIL"
 96        }
 97        fmt.Printf("%s %-20s %d/100\n", status, g.Gate, g.Score)
 98    }
 99    fmt.Printf("\nWeighted Score: %d/100\n", weightedScore)
100    fmt.Printf("Verdict: %s\n", verdict)
101
102    if verdict == "FAIL" {
103        os.Exit(1)
104    }
105}
106
107func getEnvInt(key string, defaultVal int) int {
108    val := os.Getenv(key)
109    if val == "" {
110        return defaultVal
111    }
112    var i int
113    fmt.Sscanf(val, "%d", &i)
114    return i
115}

The verdict logic is important: ALL gates must pass their individual threshold (allCriticalPass), AND the weighted average must exceed 75. A single failing gate immediately forces a FAIL verdict, no matter how high the average score is.


09.3 GitHub Actions: The Final Quality Gate Job

The aggregate score needs to run as a final job that collects the results of all the other gates. The workflow below uses if: always() so it still runs even if some jobs fail, then builds and runs the quality-gate binary.

yaml
  1# Add to .github/workflows/ci.yml — as the final gate
  2
  3jobs:
  4  # ... all the previous jobs (L1, L2, L3, L4, security, coverage, arch) ...
  5
  6  # FINAL GATE: Aggregate all scores
  7  quality-gate:
  8    name: "Final Quality Gate"
  9    runs-on: ubuntu-latest
 10    needs:
 11      - go-quality         # L1
 12      - architecture       # L2
 13      - ai-review          # L3
 14      - spec-compliance    # L4
 15      - static-security    # security
 16      - dependency-scan    # security
 17      - coverage-check     # coverage
 18    if: always()  # run even if some jobs fail
 19    permissions:
 20      pull-requests: write
 21      contents: read
 22    steps:
 23      - uses: actions/checkout@v4
 24      - uses: actions/setup-go@v5
 25        with: { go-version: '1.22' }
 26
 27      - name: Collect gate results
 28        id: collect
 29        run: |
 30          # Collect scores from each gate (via env or artifacts)
 31          # For simplicity: derive from job status
 32
 33          # L1: Go quality
 34          GO_SCORE=$([[ "${{ needs.go-quality.result }}" == "success" ]] && echo 90 || echo 0)
 35
 36          # Security
 37          SEC_SCORE=$([[ "${{ needs.static-security.result }}" == "success" ]] && echo 90 || echo 50)
 38
 39          # Coverage (from job outputs)
 40          COV_SCORE="${{ needs.coverage-check.outputs.usecase_coverage || '0' }}"
 41
 42          # Architecture
 43          ARCH_SCORE=$([[ "${{ needs.architecture.result }}" == "success" ]] && echo 95 || echo 40)
 44
 45          echo "AI_REVIEW_SCORE=${{ needs.ai-review.outputs.score || '85' }}" >> $GITHUB_ENV
 46          echo "SECURITY_SCORE=$SEC_SCORE" >> $GITHUB_ENV
 47          echo "COVERAGE_SCORE=$COV_SCORE" >> $GITHUB_ENV
 48          echo "ARCH_SCORE=$ARCH_SCORE" >> $GITHUB_ENV
 49
 50      - name: Build quality gate binary
 51        run: go build -o /tmp/quality-gate ./cmd/quality-gate/
 52
 53      - name: Run quality gate
 54        id: quality
 55        run: |
 56          RESULT=$(/tmp/quality-gate)
 57
 58          echo "result<<EOF" >> $GITHUB_OUTPUT
 59          echo "$RESULT" >> $GITHUB_OUTPUT
 60          echo "EOF" >> $GITHUB_OUTPUT
 61
 62          VERDICT=$(echo "$RESULT" | jq -r '.verdict')
 63          SCORE=$(echo "$RESULT" | jq '.weighted_score')
 64
 65          echo "verdict=$VERDICT" >> $GITHUB_OUTPUT
 66          echo "score=$SCORE" >> $GITHUB_OUTPUT
 67
 68      - name: Post final quality summary to PR
 69        if: github.event_name == 'pull_request'
 70        uses: actions/github-script@v7
 71        with:
 72          script: |
 73            const result = JSON.parse(`${{ steps.quality.outputs.result }}`);
 74            const verdict = '${{ steps.quality.outputs.verdict }}';
 75            const score = parseInt('${{ steps.quality.outputs.score }}');
 76
 77            const verdictEmoji = verdict === 'PASS' ? '✅' : verdict === 'WARNING' ? '⚠️' : '❌';
 78            const scoreEmoji = score >= 85 ? '🟢' : score >= 70 ? '🟡' : '🔴';
 79
 80            const gateRows = result.gates.map(g => {
 81              const s = g.pass ? '✅' : '❌';
 82              return `| ${s} | ${g.gate} | ${g.score}/100 | ${Math.round(g.weight*100)}% |`;
 83            });
 84
 85            const body = [
 86              `## 🎯 Final Quality Gate`,
 87              ``,
 88              `**Weighted Score:** ${scoreEmoji} ${score}/100`,
 89              `**Verdict:** ${verdictEmoji} ${verdict}`,
 90              ``,
 91              `| Status | Gate | Score | Weight |`,
 92              `|--------|------|-------|--------|`,
 93              ...gateRows,
 94              ``,
 95              verdict === 'PASS'
 96                ? '✅ **All quality gates passed. Ready to merge!**'
 97                : verdict === 'WARNING'
 98                ? '⚠️ **Some concerns found. Review before merging.**'
 99                : '❌ **Quality gate failed. Fix issues before merging.**',
100            ].join('\n');
101
102            // Find or update the final quality comment
103            const { data: comments } = await github.rest.issues.listComments({
104              owner: context.repo.owner,
105              repo: context.repo.repo,
106              issue_number: context.issue.number
107            });
108
109            const existing = comments.find(c => c.body.includes('🎯 Final Quality Gate'));
110
111            if (existing) {
112              await github.rest.issues.updateComment({
113                owner: context.repo.owner,
114                repo: context.repo.repo,
115                comment_id: existing.id,
116                body
117              });
118            } else {
119              await github.rest.issues.createComment({
120                owner: context.repo.owner,
121                repo: context.repo.repo,
122                issue_number: context.issue.number,
123                body
124              });
125            }
126
127      - name: Enforce quality gate
128        run: |
129          VERDICT="${{ steps.quality.outputs.verdict }}"
130          SCORE="${{ steps.quality.outputs.score }}"
131
132          echo "Final Quality Gate: $VERDICT ($SCORE/100)"
133
134          if [ "$VERDICT" == "FAIL" ]; then
135            echo "::error::Quality gate FAILED — fix critical issues before merging"
136            exit 1
137          fi
138
139          if [ "$VERDICT" == "WARNING" ]; then
140            echo "::warning::Quality gate WARNING — review concerns before merging"
141          else
142            echo "Quality gate PASSED"
143          fi

if: always() here is critical: without it, the final gate would be skipped whenever an upstream job fails, and we’d lose the coherent summary of everything that’s wrong.


09.4 Setting Up Branch Protection in GitHub

Once the final gate exists, we register it as a required check in branch protection. The GitHub CLI command below enables required status checks, required reviews, and linear history for the main branch.

bash
 1# Via GitHub CLI
 2
 3# Set up branch protection for main:
 4gh api repos/{owner}/{repo}/branches/main/protection \
 5  --method PUT \
 6  --field "required_status_checks[strict]=true" \
 7  --field "required_status_checks[contexts][]=L1: Go Build, Test & Lint" \
 8  --field "required_status_checks[contexts][]=L2: Architecture Boundary Check" \
 9  --field "required_status_checks[contexts][]=Final Quality Gate" \
10  --field "required_status_checks[contexts][]=Secret Detection" \
11  --field "required_status_checks[contexts][]=Dependency Vulnerability Scan" \
12  --field "required_pull_request_reviews[required_approving_review_count]=1" \
13  --field "required_pull_request_reviews[dismiss_stale_reviews]=true" \
14  --field "enforce_admins=false" \
15  --field "restrictions=null" \
16  --field "required_linear_history=true" \
17  --field "allow_force_pushes=false" \
18  --field "allow_deletions=false"
19
20# Or via UI: Settings → Branches → main → Edit

For teams practicing GitOps, the branch protection configuration should also be versioned. The workflow below applies the same rules via GitHub Actions with a manual trigger.

yaml
 1# Alternative: branch protection via GitHub Actions (GitOps approach)
 2# .github/workflows/branch-protection.yml
 3
 4name: Setup Branch Protection
 5on:
 6  workflow_dispatch:  # manual trigger
 7
 8jobs:
 9  protect:
10    runs-on: ubuntu-latest
11    permissions:
12      administration: write
13    steps:
14      - uses: actions/github-script@v7
15        with:
16          script: |
17            await github.rest.repos.updateBranchProtection({
18              owner: context.repo.owner,
19              repo: context.repo.repo,
20              branch: 'main',
21              required_status_checks: {
22                strict: true,
23                contexts: [
24                  'L1: Go Build, Test & Lint',
25                  'L2: Architecture Boundary Check',
26                  'Final Quality Gate',
27                  'Secret Detection',
28                ]
29              },
30              required_pull_request_reviews: {
31                required_approving_review_count: 1,
32                dismiss_stale_reviews: true,
33              },
34              enforce_admins: false,
35              restrictions: null,
36              required_linear_history: true,
37              allow_force_pushes: false,
38              allow_deletions: false,
39            });
40            console.log('Branch protection configured');

This GitOps approach makes the branch protection configuration versioned and reviewable, rather than a hidden UI setting that can change without a trace.


09.5 CODEOWNERS: Who Needs to Review What

Branch protection can require a review, but who must review a specific file is decided by CODEOWNERS. The file below maps paths to the responsible team, from architecture down to the domain layer.

bash
 1# .github/CODEOWNERS
 2# Format: [path] [owners]
 3
 4# Default: all changes need review from the team lead
 5*                   @santekno/engineering-lead
 6
 7# Architecture files: need review from a senior architect
 8CLAUDE.md           @santekno/senior-architect
 9.golangci.yml       @santekno/senior-architect
10scripts/check-architecture.go @santekno/senior-architect
11
12# CI/CD pipeline: need review from DevOps
13.github/workflows/  @santekno/devops-team
14
15# Security-related: need review from the security team
16.gitleaks.toml      @santekno/security-team
17.gosec.yml          @santekno/security-team
18
19# Domain layer: needs review from a domain expert
20internal/domain/    @santekno/domain-experts
21
22# Service-specific ownership
23services/order-service/     @johndoe @janedoe
24services/payment-service/   @paymentteam
25services/catalog-service/   @catalogteam

With CODEOWNERS, a change to an architecture file automatically requests a review from an architect, a CI change from DevOps, and a domain change from a domain expert — without having to manually assign a reviewer every time.


09.6 Merge Queue for High-Traffic Repos

In a repo with many parallel PRs, two PRs that both pass CI can conflict when merged at the same time. A merge queue solves this. The workflow below runs all the checks plus integration tests specifically for the merge queue.

yaml
 1# For repos with many concurrent PRs, a merge queue prevents merge conflicts
 2
 3# Enable in GitHub: Settings → General → Merge options → Enable Merge Queue
 4
 5# .github/workflows/merge-queue.yml
 6name: Merge Queue Check
 7
 8on:
 9  merge_group:  # trigger for the merge queue
10
11jobs:
12  # All the same checks as the PR checks
13  # But possibly stricter (e.g., integration tests mandatory)
14
15  all-checks:
16    name: All Checks for Merge Queue
17    runs-on: ubuntu-latest
18    steps:
19      - uses: actions/checkout@v4
20      - uses: actions/setup-go@v5
21        with: { go-version: '1.22', cache: true }
22      - run: go build ./...
23      - run: go test -race -count=1 ./...
24      - run: go vet ./...
25      - uses: golangci/golangci-lint-action@v6
26      - run: go run scripts/check-architecture.go ./...
27      # Integration tests in the merge queue (not on the PR)
28      - name: Integration tests
29        env:
30          DATABASE_URL: ${{ secrets.TEST_DATABASE_URL }}
31          REDIS_URL: ${{ secrets.TEST_REDIS_URL }}
32        run: |
33          go test -tags=integration -race ./internal/repository/... -timeout 120s

Note that integration tests run in the merge queue, not on every PR — this keeps PR feedback fast while ensuring no conflict slips through at the final merge.


09.7 Status Check Naming Convention

Clear status check names make it easy for developers to know exactly what failed without opening the log. The guide below compares good names and names to avoid.

text
 1Naming conventions that matter for status checks:
 2
 3FORMAT: "[Emoji] Description"
 4  ✅ "L1: Go Build, Test & Lint"
 5  ✅ "L2: Architecture Boundary Check"
 6  ✅ "Security: Secret Detection"
 7  ✅ "Final Quality Gate"
 8
 9AVOID:
10  ❌ "build" — too generic
11  ❌ "test-123" — not descriptive
12  ❌ "CI" — no information
13
14WHY: A clear name helps a developer know exactly what failed
15without having to click into the detailed log.
16
17In the GitHub PR check list, the developer sees:
18  ✅ L1: Go Build, Test & Lint        (passed)
19  ✅ L2: Architecture Boundary Check  (passed)
20  ❌ Security: Static Analysis         (failed: 2 HIGH issues)
21  ✅ Final Quality Gate               (passed)
22
23Vs generic names:
24  ✅ build  (what build?)
25  ✅ lint   (which linter?)
26  ❌ check  (WHAT check??)

A small investment in descriptive naming pays off every day: developers immediately know it’s “Security: Static Analysis” that failed, rather than guessing what a red “check” means.


09.8 Emergency Override Protocol

No system is 100% perfect — sometimes CI fails for reasons unrelated to the PR, and an urgent hotfix still has to merge. The protocol below governs who may override, when, and their obligations afterward.

text
 1No system is 100% perfect. There are times CI fails for reasons
 2unrelated to the PR (a flaky test, an outage, etc.)
 3and the PR still needs to be merged (an urgent hotfix).
 4
 5Emergency override policy:
 6
 7Who may override:
 8  - Tech Lead or Engineering Manager
 9  - Not a regular developer (even a senior one)
10
11Override conditions:
12  - Production is down and this PR is the fix
13  - CI fails due to an infrastructure issue (not a code issue)
14  - A time-sensitive compliance requirement
15
16How to override in GitHub:
17  1. The PR author requests an override from the tech lead
18  2. The tech lead reviews the diff manually
19  3. The tech lead uses "Bypass branch protection" (admin privilege)
20  4. Merge with a clear note
21
22Post-override mandatory actions:
23  1. Document in the PR description: why the override was done
24  2. Create an issue to fix the CI issue that caused the override
25  3. Run full verification after deploy
26  4. Post-mortem if the override was due to CI infrastructure
27
28Tracking overrides:
29  The GitHub audit log records all protection bypasses
30  Monthly review: how many times did overrides happen?
31  Too frequent = a CI reliability issue that needs fixing

What makes this protocol healthy is accountability: every override is recorded in the audit log and reviewed monthly. Too-frequent overrides are a signal of CI reliability that needs fixing, not a habit to be normalized.


09.9 Quality Gate Dashboard

Beyond per-PR enforcement, the team needs visibility into the pipeline’s health trend. The scheduled workflow below collects metrics from the GitHub API, asks the AI to summarize them, then posts to Slack every Monday.

yaml
 1# Scheduled job to generate a weekly quality dashboard
 2
 3name: Weekly Quality Dashboard
 4on:
 5  schedule:
 6    - cron: '0 8 * * 1'  # Every Monday 08:00 UTC = 15:00 WIB
 7
 8jobs:
 9  dashboard:
10    runs-on: ubuntu-latest
11    env:
12      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
13    steps:
14      - uses: actions/checkout@v4
15
16      - name: Collect metrics from the GitHub API
17        uses: actions/github-script@v7
18        id: metrics
19        with:
20          script: |
21            const { data: runs } = await github.rest.actions.listWorkflowRuns({
22              owner: context.repo.owner,
23              repo: context.repo.repo,
24              workflow_id: 'ci.yml',
25              per_page: 50,
26              created: `>=${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()}`
27            });
28
29            const successRate = runs.workflow_runs.filter(r => r.conclusion === 'success').length
30              / runs.workflow_runs.length * 100;
31
32            const avgDuration = runs.workflow_runs
33              .filter(r => r.conclusion !== null)
34              .map(r => (new Date(r.updated_at) - new Date(r.created_at)) / 1000 / 60)
35              .reduce((a, b) => a + b, 0) / runs.workflow_runs.length;
36
37            core.setOutput('success_rate', Math.round(successRate));
38            core.setOutput('avg_duration', Math.round(avgDuration));
39            core.setOutput('total_runs', runs.workflow_runs.length);
40
41      - name: Generate AI summary
42        run: |
43          METRICS="Pipeline success rate: ${{ steps.metrics.outputs.success_rate }}%
44          Average duration: ${{ steps.metrics.outputs.avg_duration }} minutes
45          Total runs last week: ${{ steps.metrics.outputs.total_runs }}"
46
47          PROMPT="Generate a weekly engineering quality digest.
48
49          Metrics:
50          $METRICS
51
52          Format as actionable bullet points:
53          - Highlight if success rate < 90%
54          - Highlight if average duration > 10 minutes
55          - Suggest improvements if needed
56
57          Tone: professional, concise"
58
59          SUMMARY=$(./scripts/claude-call.sh "$PROMPT" 300)
60
61          # Post to Slack
62          curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
63            -H "Content-Type: application/json" \
64            -d "$(jq -n \
65              --arg summary "$SUMMARY" \
66              --arg rate "${{ steps.metrics.outputs.success_rate }}" \
67              --arg duration "${{ steps.metrics.outputs.avg_duration }}" \
68              '{
69                text: (":bar_chart: *Weekly CI Quality Dashboard*\n\nSuccess Rate: " + $rate + "%\nAvg Duration: " + $duration + " min\n\n" + $summary)
70              }'
71            )"

This weekly dashboard turns raw pipeline metrics into an actionable digest — if the success rate drops below 90%, the team immediately knows there’s a bypass or reliability problem to address.


09.10 Checklist: The Complete Quality Gate Series

To make sure nothing is missed, the checklist below summarizes every component that should be in place after completing this quality-gate series.

text
 1After completing the quality-gate series, all of these should exist:
 2
 3Quality Gates:
 4□ AI code review (Copilot or a custom Claude bot)
 5□ Security audit (gosec + govulncheck + gitleaks)
 6□ Test coverage enforcement (per-layer thresholds)
 7□ Architecture linting (depguard + custom script)
 8□ Final aggregate quality gate (quality-gate binary)
 9
10Branch Protection:
11□ main branch protected (no direct push)
12□ Required status checks configured
13□ Required reviewers: 1+ approver
14□ Stale review dismissal enabled
15□ Linear history required
16
17CODEOWNERS:
18□ .github/CODEOWNERS created
19□ Architecture files: senior architect
20□ CI/CD: DevOps team
21□ Domain layer: domain experts
22□ Service ownership: service teams
23
24Documentation:
25□ Emergency override protocol documented
26□ Status check naming convention documented
27□ Weekly quality dashboard running
28□ Pipeline health metrics tracked

If every box is checked, the team has a complete enforcement system: every PR passes the same gates, with clear accountability and visibility.


09.11 Tips & Gotchas

💡 Tip 1: A required check must exactly match the job name. “Final Quality Gate” must genuinely match the name: in the YAML. A typo = a check that is never “required” but doesn’t block.

💡 Tip 2: if: always() for the final gate. The final quality gate needs to know the results of all gates, even the failing ones. if: always() ensures the job still runs.

💡 Tip 3: enforce_admins: false. Admins need to be able to bypass for an emergency. But record every bypass with an audit trail.

💡 Tip 4: Test branch protection on staging first. Before applying to main, test it on the staging or develop branch. A typo in branch protection can block every developer.

⚠️ Gotcha 1: needs that’s too strict. If the final gate needs all jobs, one optional job that fails will block the final gate. Use needs only for critical jobs.

⚠️ Gotcha 2: Forked PRs don’t have secrets. A PR from a fork can’t access secrets (ANTHROPIC_API_KEY). Handle it with if: github.event.pull_request.head.repo.full_name == github.repository.

⚠️ Gotcha 3: A flaky status check. If a flaky test causes the status check to fail often, that’s a false positive that erodes trust. Fix flaky tests before enforcing them as a required check.


09.12 Comparison: Before and After

Real numbers make the value of a quality-gate system clear. The comparison below shows the change in review time, issue count, and production bugs before and after five quality gates were applied.

text
 1BEFORE (baseline — no quality gates):
 2  PR review: 1-2 days
 3  Mechanical issues per PR: 8
 4  Overhead: 90 minutes per PR (reviewer + dev fix)
 5  Post-deploy bugs: 4-6 per sprint
 6
 7AFTER (with 5 quality gates):
 8  Automated checks: 7 minutes
 9  Mechanical issues reaching a human: 2
10  Overhead: 27 minutes per PR
11  Post-deploy bugs: 1-2 per sprint
12
13SAVINGS:
14  25 hours/week of team time → Rp 15 million/month value
15  Pipeline cost: < Rp 150,000/month in API calls

The cost-benefit ratio is extreme: a pipeline that spends less than Rp 150,000 per month produces savings equivalent to Rp 15 million per month in team time — not counting the production bugs prevented.


09.13 Enforcement Phase: Gradual Rollout

Turning on full enforcement all at once is a recipe for team resistance. The table below maps the rollout phases from shadow mode to full enforcement.

PhaseDurationBlock onWarning on
ShadowWeek 1–2NothingEverything
SoftWeek 3–4Secrets onlyAll AI gates
StandardMonth 2L1 + Secrets + CRITICALAI suggestions
FullMonth 3+All gatesNothing

The principle: start permissive, tighten based on team behavior data. Starting straight at “Full” creates bypass habits that are hard to reverse — buy-in grows from a gradual rollout, not from a sudden imposition.


09.14 Configuring Thresholds Per Branch

Quality standards don’t have to be the same on every branch. The snippet below sets a stricter threshold for main than for develop and feature branches.

yaml
 1# main: strict
 2# develop: moderate
 3# feature: permissive
 4
 5- name: Set thresholds
 6  run: |
 7    case "${{ github.ref }}" in
 8      refs/heads/main)
 9        echo "AI_THRESHOLD=80" >> $GITHUB_ENV
10        echo "COVERAGE_THRESHOLD=85" >> $GITHUB_ENV ;;
11      refs/heads/develop)
12        echo "AI_THRESHOLD=70" >> $GITHUB_ENV
13        echo "COVERAGE_THRESHOLD=75" >> $GITHUB_ENV ;;
14      *)
15        echo "AI_THRESHOLD=60" >> $GITHUB_ENV
16        echo "COVERAGE_THRESHOLD=65" >> $GITHUB_ENV ;;
17    esac

A higher standard for main (used by users) than for develop (the integration branch) than for feature branches (active development) makes enforcement proportional to release risk.


09.15 Branch Protection as a Team Agreement

The pipeline isn’t a surveillance tool; it’s a team agreement encoded in code. How you frame it to the team decides whether they comply voluntarily or look for loopholes.

“We agree that code reaching main has been reviewed by AI for mechanical issues, checked for security vulnerabilities, verified against the spec, and approved by a human reviewer.”

Frame it as “we’re building a guardrail that protects everyone from mistakes,” not “we’re monitoring your code quality.” A team that understands the purpose complies voluntarily; a team that feels watched looks for workarounds. This framing is as important as the technical configuration.


09.16 Handling Fork PRs

On open source projects, PRs from forks don’t have access to secrets like ANTHROPIC_API_KEY. The condition below ensures the AI review job is skipped for fork PRs so it doesn’t fail due to an empty secret.

yaml
 1# Fork PRs don't have access to secrets
 2# Skip AI review for forks, run standard checks only
 3
 4jobs:
 5  ai-review:
 6    if: |
 7      github.event_name == 'pull_request' &&
 8      github.event.pull_request.head.repo.full_name == github.repository &&
 9      !github.event.pull_request.draft
10    # Skips for: fork PRs, draft PRs

For an open source project, define a separate “fork review” job that runs without secrets and provides limited feedback — so external contributors still get part of the pipeline’s value.


09.17 Flaky Test Management

A status check that’s required but flaky actually destroys trust in CI. The protocol below governs how to detect, quarantine, and remove unreliable tests.

text
1Detection: a test fails in CI but passes when run locally
2Classification: "flaky" (add it to flaky-tests.txt)
3Quarantine: move it to a separate job with continue-on-error: true
4Priority: fix within 2 sprints or delete

The bitter rule: a required check that fails 10% of the time is worse than no check at all — it trains developers to ignore CI failures, which destroys the entire value of enforcement.


09.18 Cost Summary

Enforcement this complete sounds expensive, but the cost is small. The breakdown below shows the real monthly cost for a 5-developer team with 24 PRs per week.

text
 1Monthly cost for a 5-developer team, 24 PRs/week:
 2
 3GitHub Actions (compute):         ~$3/month
 4Anthropic API (haiku, all uses):  ~$2/month
 5GitHub Copilot Business:          $95/month (probably already paid)
 6
 7Total additional cost:            ~$5/month (excluding Copilot)
 8
 9Value:
10  25 hours/week saved in review overhead
11  ~70% reduction in post-deploy bugs
12  Full spec compliance enforcement
13  Architecture drift prevention
14  Automatic security scanning
15
16ROI: > 1000% on the $5/month API cost alone

With an additional cost of about $5 per month excluding Copilot, the ROI exceeds 1000% — a number that makes the question “is it worth it?” almost irrelevant.


09.19 Summary: The Quality Gate Series

This quality-gate series concludes with five gates working together:

AI code review: mechanical issues caught before a human reviewer sees them, with objective scoring and graduated enforcement.

Security audit: secrets, known CVEs, static analysis, and semantic security review, layered.

Test coverage: per-layer thresholds, spec-to-AC mapping, and meaningful gap analysis.

Architecture linting: Clean Architecture enforced automatically on every PR.

Branch protection: aggregate scoring, required status checks, and CODEOWNERS as the final enforcement.

The end state is clear: every developer, every PR, every day passes the same quality bar — regardless of who reviews it, what time it is, or how tight the deadline.


09.20 Transition to the Next Article

By this point, our pipeline is good at evaluating code: scoring, blocking, and grading. But a mature pipeline doesn’t only evaluate — it also produces artifacts useful to the team.

In the next article we begin the automation phase: making the pipeline auto-generate PR descriptions from the spec and diff, so reviewers immediately grasp the change’s context without the author having to explain it manually.

Related Articles

💬 Comments