# Where a check counts

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.

```text
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:

```yaml title="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.

```yaml title="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:

```yaml title="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
```

```yaml title=".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](https://hushrun.com/docs/ci.md#any-language). `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:

```text
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](https://hushrun.com/docs/run.md#monorepos), 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-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](https://hushrun.com/docs/promotion/evidence.md) 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.25"` | 1.25, 1.25.0, 1.25.14 | Yes |
| `"1.25"` | 1.250, 1.2, 1.26 | No |
| `"24"` | 24.1.0 | Yes |
| `"1.25"` | 1.25rc2 | No: a pre-release isn't the release |
| `"1.94"` | 1.94.0-nightly | No, 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:

```text
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.

| 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.25, this is go1.24.3` |
| 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](https://hushrun.com/docs/teams.md) 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](https://hushrun.com/docs/ci.md#ci-only) on the repository's page, or a [CI-only stage](https://hushrun.com/docs/promotion/evidence.md#ci-only).

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.
