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.
blog/pr-gatesEvery 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 gatevalidate --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:
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 --strictIt 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:
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=92A 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" gate | A gate that gates |
|---|---|---|
| Missing variable | expands to empty, passes | validate --strict fails it |
| Coverage below floor | warning, still green | assert exits non-zero |
| Order of checks | all-or-nothing | lint, validate, test — stop at first failure |
| Reproduce a red check | push to the PR | orchstep run gate --var ... locally |
| Cost of a typo'd var | found in prod | found 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
- Variables & Outputs — what
validate --strictchecks - Error Handling — assertions, retries, and exit codes
- Quick Start — your first workflow in two minutes
Got a required check that's green for the wrong reasons? Make it validate first — curl -fsSL https://orchstep.dev/install.sh | sh.
curl -fsSL https://orchstep.dev/install.sh | sh