BLOG/TEAM CI/CD
THEME
TEAM CI/CD

PR gates that actually gate

A green check that doesn't catch the thing you added it to catch is worse than no check. Here's a PR gate that lints, validates every variable resolves, and fails on a missing var before a single test runs.

Jun 2, 2026 OrchStep Team 6 minROLE: DevOps EngineerSCALE: Any
RUNNABLE DEMO
Full source for this post: blog/pr-gates
VIEW SOURCE

Every team has a required check that doesn't actually check. The "lint and test" gate that's green because lint exited zero on a config it never loaded. The coverage gate that passes because the coverage step silently no-op'd when a variable was empty. The merge button turns green, the thing you added the gate to prevent ships anyway, and you find out in production.

The failure mode is almost always the same: a gate that runs but doesn't fail on the condition you care about. A missing variable expands to empty string, the command runs against nothing, exits zero, and the gate waves it through.

A real gate fails fast and fails loud. Lint. Then prove every variable the pipeline references actually resolves. Then test. Stop at the first one that's wrong.

Lint, validate, test — in that order

The order matters. Cheap checks first; the expensive test suite only runs if the inputs are sane:

orchstep validate gate --strict && orchstep run gate

validate --strict is the step most gates skip. It statically checks that every variable the task references resolves — under each environment — before anything executes. A typo'd variable name or an unset value fails here, in milliseconds, instead of expanding to empty and quietly passing.

The gate that fails on a missing var

Here's the gate task. It lints, runs the suite, and asserts coverage stays above the floor. The coverage numbers are variables — and that's the point: if one is missing or unset, the gate stops, it doesn't shrug:

orchstep.yml
name: app
# The PR gate: lint, then validate every var resolves under --strict,
# then test. A missing var fails `validate`, so the build fails before
# a single test runs — not three steps too late.
defaults:
  coverage_floor: "80"
  changed_coverage: "92"

tasks:
  # `orchstep run gate`
  gate:
    steps:
      - name: lint
        func: shell
        do: echo "linting"
      - name: test
        task: test
      - name: coverage
        func: assert
        args:
          condition: '{{ ge (atoi vars.changed_coverage) (atoi vars.coverage_floor) }}'
          message: "coverage {{ vars.changed_coverage }}% is below the {{ vars.coverage_floor }}% floor"
      - name: pass
        func: shell
        do: echo "gate passed — coverage {{ vars.changed_coverage }}% >= {{ vars.coverage_floor }}%"

  test:
    steps:
      - name: unit
        func: shell
        do: echo "running unit tests"
      - name: integration
        func: shell
        do: echo "running integration tests"

The coverage step is a real assert, not an echo that hopes for the best. When changed_coverage drops below the floor, the assertion fails, the step exits non-zero, and the job goes red:

orchstep run gate --var changed_coverage=71
# ... [FAIL] coverage
# Result: failed   (exit code is non-zero — the merge stays blocked)

That's a gate doing its job: a number below the bar produces a red check, every time, not a green one with a warning nobody reads.

Why --strict is the part that matters

The classic broken gate has a step that passes a deploy_token variable to a deploy command. Someone renames the variable to api_token and forgets this reference. Without validation, the old reference expands to empty, the command runs with no token, fails in a confusing way — or worse, succeeds against the wrong target.

orchstep validate gate --strict catches that statically, before the runner spends a minute getting there:

orchstep validate gate --strict

It walks every variable reference in the task and confirms it resolves under each environment. A missing one is an error, not an empty string. Full details: Validation.

Wire it as the required check

The gate is one task, so the CI side is one line that the branch protection rule requires:

workflows/pr-gate.yml
name: pr-gate
on: [pull_request]

jobs:
  gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # setup-orchstep installs + caches the CLI once; this job runs several
      # orchstep commands (validate, then run). Manual install alternative:
      #   - run: curl -fsSL https://orchstep.dev/install.sh | sh
      - uses: orchstep/setup-orchstep@v1
      - name: Validate then gate
        run: |
          orchstep validate gate --strict
          orchstep run gate --var changed_coverage=92

A matrix over changed_coverage values, or per-package coverage floors, slots in the same way — each cell is another --var.

What you actually gained

Concern"Green" gateA gate that gates
Missing variableexpands to empty, passesvalidate --strict fails it
Coverage below floorwarning, still greenassert exits non-zero
Order of checksall-or-nothinglint, validate, test — stop at first failure
Reproduce a red checkpush to the PRorchstep run gate --var ... locally
Cost of a typo'd varfound in prodfound in milliseconds

If your existing checks genuinely fail on the conditions you care about, leave them — the goal isn't OrchStep for its own sake, it's a gate that's red when it should be red. The leverage here is validate --strict: it turns "the variable was empty so the check passed" from a class of incident into a build failure.

Where to go next

Got a required check that's green for the wrong reasons? Make it validate first — curl -fsSL https://orchstep.dev/install.sh | sh.

#CI#PR-GATES#VALIDATION#DEVOPS
Try it in two minutes — one binary, no signup.
curl -fsSL https://orchstep.dev/install.sh | sh

RELATED — TEAM CI/CD