Skip to content

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:

hushrun.yaml
version: 1
repository: github.com/acme/ios
counts_from:
  os: macos

A 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.

hushrun.yaml
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:

hushrun.yaml
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: anywhere
.github/workflows/ci.yml
on: 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: api

These 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. swiftlint alone earns no rule, since it reads the same anywhere. Nor does fastlane: fastlane scan stays as it is, since it writes reports to fastlane/test_output, which a skip wouldn't, and a lane such as fastlane test can do anything.
  • Another check is at that level: a Node job on macos-latest, make test, or a script package.json wraps. CI's files don't say where a script runs.
  • A check also runs on Linux or Windows. swift test does.
  • The file can't say which system it is: a runs-on holding 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.yml names a system.
  • hushrun.yaml has said what it wants: a counts_from at any level, anywhere included, stays exactly as written. So does a file that lists checks, 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

KeyWhat it means
osmacos, linux or windows
archarm64 or amd64
go, rust, node, xcodeA quoted version prefix
envVariables that must hold exactly these values: env: {SANDBOX: go-7}

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:

RuleMachineMatch
"1.25"1.25, 1.25.0, 1.25.14Yes
"1.25"1.250, 1.2, 1.26No
"24"24.1.0Yes
"1.25"1.25rc2No: a pre-release isn't the release
"1.94"1.94.0-nightlyNo, unless the rule writes "1.94.0-nightly"

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 PATH finds, 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, PATH or a version manager's variable set in the command, an exec form such as mise exec, asdf exec, fnm exec, volta run or nvm exec, and a launcher that starts or pins the tool itself, such as npx node@22 t.js. It reads past env, nice, time and timeout, and through sh -c '…'. npx vitest run starts 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 counts

One 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.

CaseState
A pass under the keypassed, and it says who recorded it
It failed on this codemissing, with the usual command, or with what clears it when a run here wouldn't count
No result, and a run here didn't countci, with what the run said: a run here didn't count: needs go 1.25, this is go1.24.3
No result, and this machine doesn't meet the rule's os, arch or envci, with why: needs linux, this is macos
No result otherwise, a tool in the rule includedmissing, 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 test or make test, reads as the command it is, and what runs inside isn't measured. Neither is a script's own export PATH=…, or use-node-version in .npmrc.
  • arch is the running hushrun's, so an x86-64 build under Rosetta says amd64.
  • A rule's env values are in hushrun.yaml in plain text. Use them to name a sandbox, never to hold a secret. A rule can't name HUSHRUN_AGENT, HUSHRUN_TOKEN or HUSHRUN_MACHINE_KEY.
  • hushrun.yaml with counts_from fails to parse before the release that added it, so update every machine and CI job that reads the file first.
Markdown