Where a check counts
Updated
A pass on one machine stands in for a run on any other. That is what skipping is, and for most checks it is right. Some checks only mean something in one place: a Swift check passed on Linux says little of an iOS app, and a pass on Go 1.26 says little of a backend that ships on 1.25.
counts_from in hushrun.yaml says what a check needs. hushrun run measures it wherever the check runs. A run on a machine that doesn't meet it still runs and keeps its exit code, but leaves no pass, so nothing can skip on it.
hushrun: go-test running: this run won't count: needs go 1.25, this is go1.24.3 hushrun: go-test passed in 6s; it won't count: needs go 1.25, this is go1.24.3
Examples
An iOS app, whose checks need a Mac:
version: 1
repository: github.com/acme/ios
counts_from:
os: macosA Go backend, whose checks need Linux and the Go it ships on. Quote a version: YAML reads 1.20 as the number 1.2, and hushrun refuses an unquoted one.
version: 1
repository: github.com/acme/api
counts_from:
os: linux
go: "1.25"A monorepo, with a rule per project, and one check that counts anywhere. Each hushrun run names its scope, since the file has several:
version: 1
repository: github.com/acme/shop
backend: https://api.hushrun.com/o/acme
scopes:
ios:
include: ["ios/**"]
counts_from: {os: macos}
api:
include: ["api/**"]
counts_from: {os: linux, go: "1.25"}
checks:
swiftlint:
counts_from: anywhereon: push
jobs:
ios:
runs-on: macos-latest
steps:
- uses: actions/checkout@v5
- run: hushrun run --scope ios test -- xcodebuild test -scheme App
working-directory: ios
- run: hushrun run --scope ios swiftlint -- swiftlint --strict
working-directory: ios
api:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- run: hushrun run --scope api test -- go test ./...
working-directory: apiThese jobs also need hushrun installed: see In CI. swiftlint is the same on every machine that has it, so its check has no rule, and checks.swiftlint is all it needs: kind is source unless it says otherwise.
What hushrun init writes
hushrun init writes one rule by itself, for an Apple project, and shows it in its plan like any other change:
hushrun.yaml counts_from: {os: macos}: every check hushrun runs here is on macOS in CI, with Xcode or Swift, so a pass on another system won't count
It goes at the top of hushrun.yaml when the repository is one scope, and in a project's scope when there are several. Every other rule is yours to write, because a wrong rule costs skips quietly: runs stop leaving a pass, and the only sign is a line in a log. So init writes this one only when xcodebuild or swift goes through hushrun at that level, in jobs whose runs-on is one macos-* runner, such as macos-15 or macos-latest, and every other check there is one of those or swiftlint. Commands it leaves as they are count too, since one of those could go through hushrun later. A step hushrun doesn't know isn't a check of its, so a rule doesn't touch it, and it doesn't stop one.
It writes none, and says nothing of one, when:
- Nothing there needs a Mac.
swiftlintalone earns no rule, since it reads the same anywhere. Nor does fastlane:fastlane scanstays as it is, since it writes reports tofastlane/test_output, which a skip wouldn't, and a lane such asfastlane testcan do anything. - Another check is at that level: a Node job on
macos-latest,make test, or a scriptpackage.jsonwraps. CI's files don't say where a script runs. - A check also runs on Linux or Windows.
swift testdoes. - The file can't say which system it is: a
runs-onholding an expression, such as${{ matrix.os }}, or a list of labels, as a self-hosted runner has. - A job hands off what it runs: a job with
uses:, which is another workflow, or a job off a Mac that uses a local action, which can run a check of its own. - The repository is on GitLab. A job's tags are whatever its runners' owner chose, so nothing in
.gitlab-ci.ymlnames a system. hushrun.yamlhas said what it wants: acounts_fromat any level,anywhereincluded, stays exactly as written. So does a file that listschecks, and a file with stages can't hold a rule.- The repository is already set up. A rule gives its checks new keys, and one you removed stays removed, so running init again changes nothing.
When nothing else would write hushrun.yaml, init writes one for the rule, with version and repository. Without an origin remote it can't name the repository, so there is no file. With a package.json, init still wraps CI's commands, and the plan says what to add by hand. Without one it wraps nothing, so there is no check for a rule to cover.
A project with a Package.swift, or only an .xcodeproj, stays in one scope with the rest of the repository, so an iOS project beside a Go backend gets no rule from init: Go's check is at the same level. Declare the two scopes in hushrun.yaml first, as in the monorepo above, and init writes the rule in the iOS scope, whatever it leaves in the other, or add it yourself.
What it takes
| Key | What it means |
|---|---|
os | macos, linux or windows |
arch | arm64 or amd64 |
go, rust, node, xcode | A quoted version prefix |
env | Variables that must hold exactly these values: env: {SANDBOX: go- |
Each key takes one value or a list, and a list means any of them: go: ["1.25", "1.26"].
The nearest level wins, whole: checks.<name>, else scopes.<name>, else the top of the file. Nothing merges, so a check with counts_from: {node: "24"} drops its scope's os. counts_from: anywhere means no rule at that level. A rule in checks.<name> applies by name in every scope.
counts_from doesn't go in a file with stages, where evidence has its own rules.
What counts as a match
A version is compared by dotted segment, so a prefix names a release and everything under it:
| Rule | Machine | Match |
|---|---|---|
"1. | 1.25, 1.25.0, 1.25.14 | Yes |
"1. | 1.250, 1.2, 1.26 | No |
"24" | 24.1.0 | Yes |
"1. | 1.25rc2 | No: a pre-release isn't the release |
"1. | 1.94.0-nightly | No, unless the rule writes "1. |
These don't count:
- A tool the rule names that isn't there, or can't be measured. If it fails, says no version, or takes more than 10 seconds, the run doesn't count.
- A variable that is unset or holds something else. hushrun doesn't print what it held.
- A command that picks its own toolchain while the rule names that tool. hushrun measures what
PATHfinds, so it reads the command's words for anything else:cargo +nightly test,rustup run,go1.24.3 test, a tool given by its path such as/opt/go1.24/bin/go test,PATHor a version manager's variable set in the command, an exec form such asmise exec,asdf exec,fnm exec,volta runornvm exec, and a launcher that starts or pins the tool itself, such asnpx node@22 t.js. It reads pastenv,nice,timeandtimeout, and throughsh -c '…'.npx vitest runstarts something else, on the machine's node, and counts.
hushrun run measures a tool in the check's directory, with the PATH the command gets, so a version manager answers as it does for the check. It measures before the command, to warn you, and again after it, because a toolchain may arrive during the run: that decides. node is asked twice when a node_modules/.bin in the repository holds one, because npm test runs on that one and node t.js on the machine's, and hushrun can't tell which yours does: both have to meet the rule.
What a run does
A pass recorded by a machine that met the rule skips on any machine, and skipping measures nothing. So an agent on Linux skips the iOS check a Mac passed. The rule says who can leave a pass, not who can use one.
Otherwise the command runs in place, with its own exit code. A run that meets the rule records as usual, and its last line says so:
hushrun: go-test passed in 6s on linux, go1.25.14; it countsOne that doesn't records nothing, and costs nothing against your plan. Without a rule, nothing is measured, and a run prints what it always did.
Every result keeps where it ran, such as linux/arm64, and the tools a rule measured. That informs: only the key decides. The rule is part of a run's key, so changing a rule runs its checks once.
In hushrun ready
hushrun ready looks a check up with the same key, and asks no server anything more. It and the pre-push hook start none of your tools and nothing your repository can name, whatever it holds, so they read only what needs no tool: a rule's os, arch and env. The rest is a run's to say.
| Case | State |
|---|---|
| A pass under the key | passed, and it says who recorded it |
| It failed on this code | missing, with the usual command, or with what clears it when a run here wouldn't count |
| No result, and a run here didn't count | ci, with what the run said: a run here didn't count: needs go 1. |
No result, and this machine doesn't meet the rule's os, arch or env | ci, with why: needs linux, this is macos |
| No result otherwise, a tool in the rule included | missing, with the usual command |
ci means nothing here changes it, and CI or another machine has to run it. It doesn't count against the code, so an agent isn't refused a push for a check this machine can't pass. To know that every check really passed, use hushrun ready --strict, which exits 0 only then.
So on the wrong machine, a check with a tool in its rule costs one run for each change of the code: missing, then a run, which warns you before the command starts, then ci. That ci stands for this exact code on this machine while nothing is recorded for it. If you fix the machine, run the check once by hand, which counts and clears it, or leave it to CI.
A failure outranks all of this, wherever it was recorded: it came from a machine that met the rule, and the hook is there to stop a failing check being pushed. So the check stays missing, and an agent's push stays refused, until the code changes or a machine that meets the rule passes it. When a run here wouldn't count, ready prints no command, and says that.
A rule on a tool needs a cache that keeps files, since that is how ready learns what a run found. Where it can't keep one, read-only or set to nothing, the run says so, and ready says ci instead of asking for ever.
A rule for a name nothing runs is probably a typo, and ready says so: hushrun.yaml has a rule for go-tset, and nothing runs it.
What it isn't
A rule is about where a run happened, never about whose evidence counts. Whose evidence counts is membership and the server's settings.
What a machine says is its own word. go env GOVERSION prints what it prints, and a wrapper can print anything. Nothing here signs or verifies it. counts_from stops mistakes, not people.
It sits in the change it governs. That is on purpose: a pull request that moves to Go 1.26 moves the rule with it, and a change that weakens a rule is reviewed like any other line. When a laptop's or an agent's pass must not stand in for CI at all, that is a setting a pull request can't reach: CI reuses only CI's results on the repository's page, or a CI-only stage.
Other limits:
- hushrun reads a command's words, for honest mistakes rather than determined ones. A wrapper it doesn't know, such as
docker run golang:1.24 go testormake test, reads as the command it is, and what runs inside isn't measured. Neither is a script's ownexport PATH=…, oruse-node-versionin.npmrc. archis the running hushrun's, so an x86-64 build under Rosetta saysamd64.- A rule's
envvalues are inhushrun.yamlin plain text. Use them to name a sandbox, never to hold a secret. A rule can't nameHUSHRUN_AGENT,HUSHRUN_TOKENorHUSHRUN_MACHINE_KEY. hushrun.yamlwithcounts_fromfails to parse before the release that added it, so update every machine and CI job that reads the file first.