JTjason.teixeira() Docs
services Book a call →
Home / Docs / How-to guides / Build a Quality Ratchet That Blocks Regressions
How-to guides

Build a Quality Ratchet That Blocks Regressions

Make quality a one-way door: the gate stores the last good score and refuses anything worse.

A quality ratchet is a CI check that remembers your last good score and fails the build if a new one comes in lower. It turns quality into a one-way door. The number can go up or stay flat, but never down. This guide builds one with a small Python script, a checked-in baseline file, and GitHub Actions. Budget about thirty minutes.

#Before you start

  • A repo with GitHub Actions enabled.
  • A command that already produces a single quality number, such as coverage percent, a lint count, or an eval score.
  • Python 3.9 or newer for the ratchet script.
  • Write access to commit a baseline file to the repo.

#Store the current score as the baseline

The ratchet needs a floor to compare against. Commit a small JSON file holding your current score. Check it into git so the floor is versioned and moves only through a real commit.

quality-baseline.json
{
  "metric": "coverage",
  "score": 82.4,
  "higher_is_better": true
}

#Write the ratchet check

This script reads the new score, compares it to the baseline, and exits non-zero if it got worse. A non-zero exit is what fails the CI job. It also bumps the baseline in place when the score improves, so the last good score becomes the new floor.

ratchet.py
import json, sys

BASELINE = "quality-baseline.json"

def main(new_score: float) -> int:
    with open(BASELINE) as f:
        base = json.load(f)

    old = base["score"]
    higher_better = base["higher_is_better"]
    regressed = new_score < old if higher_better else new_score > old

    if regressed:
        print(f"RATCHET FAIL: {new_score} is worse than floor {old}")
        return 1

    if new_score != old:
        base["score"] = new_score
        with open(BASELINE, "w") as f:
            json.dump(base, f, indent=2)
        print(f"Ratchet raised: {old} -> {new_score}")
    else:
        print(f"Ratchet held at {old}")
    return 0

if __name__ == "__main__":
    sys.exit(main(float(sys.argv[1])))

#Run it locally

Feed it a score below the floor and confirm it fails. Then feed it a score above the floor and confirm it passes and rewrites the baseline. That is the whole behavior, so test both directions before trusting it in CI.

terminal
python ratchet.py 80.1   # -> RATCHET FAIL, exit 1
python ratchet.py 85.0   # -> Ratchet raised: 82.4 -> 85.0, exit 0

#Wire it into GitHub Actions

Run your real quality command, capture the number, and pass it to the ratchet. Here the score is set by hand, but any command that prints a number works. The job fails automatically when the script exits non-zero. The workflow runs on pull requests and on pushes to main, and it grants write access so the raise step in the next step can commit.

.github/workflows/ratchet.yml
name: quality-ratchet
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: write

jobs:
  ratchet:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Produce score
        run: |
          # replace with your real command that emits a number
          echo "SCORE=82.4" >> "$GITHUB_ENV"
      - name: Ratchet check
        run: python ratchet.py "$SCORE"

#Decide how the floor gets raised

On a pull request the improved score updates the file in the runner, but nothing commits it. The simplest honest setup is to raise the floor only on pushes to your main branch, so it moves after code actually merges. Pull requests just get checked against the current floor. The step below runs only on main, which is why the workflow above also triggers on push to main.

.github/workflows/ratchet.yml
# add to the job, after the ratchet step
      - name: Commit raised baseline
        if: github.ref == 'refs/heads/main'
        run: |
          git config user.name  "ratchet-bot"
          git config user.email "ratchet@users.noreply.github.com"
          git add quality-baseline.json
          git commit -m "chore: raise quality floor" || echo "no change"
          git push

#Make it a required check

In branch-protection settings, mark the quality-ratchet job as a required status check for merging. Now a pull request that drops the score cannot merge until someone fixes it. Keep the baseline commit easy to override on purpose. Sometimes a metric legitimately drops, and you need to lower the floor by hand in a reviewed commit.

#Watch out for

  • A noisy metric will fail builds for no real reason. If your score wobbles by half a point between identical runs, add a small tolerance to the comparison or the ratchet becomes a coin flip.
  • Committing the baseline from CI needs contents: write permission. Scope the commit to main and guard it so an unchanged file does not error.
  • A ratchet only guards the one number you feed it. A green ratchet on coverage says nothing about whether the tests are any good. Pick a metric that actually means something, or you are just protecting a vanity number.

#What you built

You have a check that stores your last good score, blocks any pull request that comes in worse, and raises its own floor when quality improves on main. It is a few lines of Python and one workflow, and it makes backsliding a deliberate choice. Point it at a metric you trust, then add a second ratchet for a different dimension once the first one earns its keep.

Want this built into your pipeline?
Get a free mini-eval on your live AI feature, or book a call to have it wired in properly.
Build your plan → 2 minor book a call →
© 2026 Jason Teixeira · Sage Ideas LLC · Documentation home · privacy · terms