# Deploys

This page explains deploying with hushrun. Before any work, the deploy asks hushrun's [planner](#planner) what's left to do for these exact files: build, run the checks that have no pass, deploy, or nothing. Each step is recorded as a signed event on hushrun's server.

## When you need it

[`hushrun run -- COMMAND`](https://hushrun.com/docs/run.md) skips a check these exact files already passed. A deploy pipeline does the same for a whole deploy:

- It runs only the checks that have no valid evidence for these files.
- It builds an image once for these files. A re-run, or a revert to files that passed before, deploys that image again by digest, with no build and no check to repeat.
- It doesn't deploy again what's already running.
- It records every check, build and deploy as a signed event, so every decision can be checked later.

| You want | Use |
|---|---|
| CI, laptops and agents to skip checks that already passed | `hushrun run -- COMMAND` alone |
| A deploy that builds and checks the same files once | A deploy pipeline |
| A signed record of every check, build and deploy | A deploy pipeline |

You can use both, but they don't share results. A plan counts only evidence recorded for the deploy, by `hushrun run CHECK` or `hushrun attest`. It never counts the results `hushrun run -- COMMAND` keeps.

## Source digests and scopes

hushrun hashes the files of each deployable scope into a **source digest**. Commits, branches and the version control system don't enter it, so two commits with the same files have the same digest. Here the newest commit on `main` reverts the one before it, so it has the files `main~2` had:

```console
$ hushrun digest --rev main~2
sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8  default
$ hushrun digest --rev main
sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8  default
```

- Without `scopes` in `hushrun.yaml`, one scope, `default`, covers every file.
- A monorepo declares one scope per deployable. Each covers everything that decides its artifact and its deployment: sources, build files, Dockerfile and deploy workflow.
- `digest.exclude` leaves out files that never affect what ships, such as `**/*.md`. A commit that changes only those deploys nothing new.
- `hushrun digest --explain` lists every file, with the pattern that decided its scope.

```yaml title="hushrun.yaml"
scopes:
  api:
    include: ["services/api/**", "go.mod", "go.sum", ".github/workflows/hushrun.yml", "hushrun.yaml"]
  web:
    include: ["services/web/**", ".github/workflows/hushrun.yml", "hushrun.yaml"]
digest:
  exclude: ["**/*.md"]
```

## What a deploy requires

The `deploy` block names the branch a deploy runs from and the checks it needs. The whole `hushrun.yaml` of a repository that deploys from `main`:

```yaml title="hushrun.yaml"
version: 1
repository: github.com/acme/api

checks:
  lint:        { run: pnpm lint }
  unit:        { run: pnpm test, timeout: 20m }
  integration: { run: pnpm test:integration, timeout: 30m, max_age: 7d }

artifacts:
  image:
    type: oci
    location: us-docker.pkg.dev/acme/api/server

deploy:
  branch: main
  require: [lint, unit, integration]
```

- `branch`: the deploy runs only from this branch.
- `require`: the checks a deploy needs, every check with a `run` when the file doesn't list them. A check depends only on the files, so a pass recorded on any machine that [counts](#who-counts) satisfies it. With `max_age`, such as `7d`, older evidence has expired.
- `artifacts`: each image, and the registry repository its build pushes to and the deploy takes it from.
- `lease`, `dedupe`, `allow_force` and `on_repository_error` are in [Configuration](https://hushrun.com/docs/reference/configuration.md#deploy).

A check of what was just deployed, such as end-to-end tests against the live service, runs in your deploy step: if it fails, the job fails, and the run records `failed`. To keep its result too, record it with `hushrun attest --check e2e=pass` before `hushrun mark passed`.

> **Note:** A `hushrun.yaml` written before hushrun 0.9.0 may still have `stages`, a check's `kind` or an artifact's `locations`. `hushrun run`, `hushrun ready` and the pre-push hook read past them with a warning that names the line, and keep skipping. `hushrun plan` refuses the file, since a deploy that ignored `stages` could skip what they required. Move a stage's `branch`, `require` and `lease` under `deploy`, give each artifact one `location`, and delete the rest: see [Keys from before 0.9](https://hushrun.com/docs/reference/configuration.md#retired).

## Evidence

Each step of a deploy records what it did as a signed event on hushrun's server:

- check results, from `hushrun run CHECK` or `hushrun attest --check`;
- artifacts, from `hushrun attest --artifact`;
- each attempt's lifecycle (`started`, `deployed`, `passed`, `failed` or `superseded`), from `hushrun plan --start`, `hushrun mark` and `hushrun guard`.

Every signature gets a trusted timestamp, from Sigstore's timestamp authority by default. CI appends events and never overwrites or deletes one. Old events go only through an organization admin's `hushrun repository gc`.

`hushrun verify` shows who signed each event of a digest:

```console
$ hushrun verify --scope default --digest sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8
ok        v1/events/default/bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8/01M63WSKM1RC1PA3Y6HHBTFNFC.started._.json: started signed by ci (custody ci) for sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8, with identity https://github.com/acme/api/.github/workflows/hushrun.yml@refs/heads/main, timestamped at 2026-10-29T13:15:08.802Z
ok        v1/events/default/bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8/01M63WSKS5VZAK8574WJTKDWS8.check._.json: check signed by ci (custody ci) for sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8, with identity https://github.com/acme/api/.github/workflows/hushrun.yml@refs/heads/main, timestamped at 2026-10-29T13:15:08.966Z
…
```

## Who counts

No file lists who may sign. An event counts when one of these signed it:

- **A member's machine.** `hushrun login` registers the machine it runs on. Its events count while its owner is a member of the repository's organization. See [Machines](https://hushrun.com/docs/teams.md#machines).
- **An agent of the organization.** Every agent token comes with a machine key of its own. See [Evidence from agents](https://hushrun.com/docs/deploy/evidence.md#agents).
- **The repository's own CI.** On GitHub Actions, a job with `id-token: write` signs keylessly, as the repository's CI. See [Keyless signing](https://hushrun.com/docs/deploy/github.md#keyless).

hushrun's server answers who these are. It signs that answer with the **log key** that `hushrun.yaml` pins, so it can't change the answer silently ([Transparency log](https://hushrun.com/docs/deploy/transparency.md)). `hushrun repository signers` prints it.

Removing someone stops the future, not the past. When a member leaves the organization or logs out, or an agent token is revoked, what they sign from then on stops counting. What they signed before keeps counting, by its trusted timestamp.

### CI's evidence only

A repository's deploys can count evidence from its CI only. An admin turns on **CI reuses only CI's results** in the repository's dashboard **Settings**, under **CI rules**: the same switch that stops CI skipping on laptops' and agents' passes ([CI reuses only CI's results](https://hushrun.com/docs/ci.md#ci-only)). It's never in `hushrun.yaml`, so no pull request can turn it off.

While it's on:

- checks, artifacts and lifecycle events count only when the repository's CI signed them;
- what others signed before it was turned on keeps counting, and what they signed while it was on never does, even after it's turned off;
- `hushrun ready` leaves the deploy's checks with CI, since a pass on a laptop wouldn't change what the deploy runs.

`hushrun policy explain` shows whether the repository is CI-only, since when, and whose events count for each required check.

> **Note:** A GitLab pipeline signs as one of the organization's agents, so don't turn this on for a GitLab project: its own pipeline's events wouldn't count. See [GitLab CI](https://hushrun.com/docs/deploy/gitlab.md#signing).

## The planner

A **plan mode** is the one thing a deploy does with these files, decided before any work. A pipeline's first job runs `hushrun plan --start` to get it, and every later job carries out that plan instead of deciding again.

### How it decides

The planner asks these questions in order. The first answer that names a mode decides:

1. **Is the run on the deploy's branch?** If not: `blocked`. Run it from `deploy.branch`.
2. **Is the run forced?** With `--force-full`, or `HUSHRUN_FORCE_FULL=true`: `full`. It builds and runs every required check, whatever the evidence. With `allow_force: false` in `deploy`, a forced run is `blocked` instead.
3. **Does the repository already run these files?** On the first attempt of a run that a push or another workflow started (the [`dedupe`](https://hushrun.com/docs/reference/configuration.md#deploy) triggers): `current`. There's nothing to deploy.
4. **Is there a reusable artifact?** That's one built for these files by an earlier attempt that passed, pinned by digest at the artifact's `location`. If not: `full`, which builds and runs the [checks to run](#checks-to-run).
5. **Does every required check have valid evidence?** If so: `promote`. If not: `refresh`, which runs the checks to run, then deploys the reused artifact.

`promote` keeps the name it had when hushrun moved builds between environments. It now means: deploy a build of these files that already passed, and run nothing.

### What each mode means

| Mode | Deploys? | The CI job | What you do |
|---|---|---|---|
| `promote` | Yes: the image an earlier attempt built and checked | Green | Nothing |
| `refresh` | Yes: that image, once the checks to run pass | Green, or red if a check fails | Nothing, or fix the check |
| `full` | Yes: a new build, once the checks to run pass | Green, or red if a check fails | Nothing, or fix the check |
| `current` | No: the repository already runs these files | Green, with the deploy skipped | Nothing. To deploy them again, re-run it or start it by hand |
| `blocked` | No | Red: `hushrun plan` exits 3, which fails the job on purpose | What the reason says |

Only `promote` rebuilds nothing and repeats no check.

### Which checks run

A required check runs when its newest evidence for these files, from a signer that counts, is:

- **missing:** none was recorded, or only for an older definition of the check, or from another image or platform than the check pins;
- **failed:** the newest result is a fail;
- **expired:** it's older than the check's `max_age`.

The plan lists them as `checks to run`. `hushrun status` shows each scope's mode and required checks for these files, and records nothing.

### What a plan prints

The first line is the mode and the reason, which says what happens next. The lines under it are the checks to run, the image to deploy, and each required check's state. For a scope other than `default`, the line starts with the scope's name, such as `api promote: …`.

These are from the repository above.

`promote`: an earlier attempt built and passed these files, so the deploy takes its image.

```console
$ hushrun plan
promote: every required check is met: deploying image us-docker.pkg.dev/acme/api/server@sha256:3cae5d5c7887d7eb92e7e4a157ea3bc4d155b7d9aad6cd9f297c72d860f4b157 by digest
  image: us-docker.pkg.dev/acme/api/server@sha256:3cae5d5c7887d7eb92e7e4a157ea3bc4d155b7d9aad6cd9f297c72d860f4b157
  requirement lint                 satisfied
  requirement unit                 satisfied
  requirement integration          satisfied
```

`refresh`: integration's evidence is older than its `max_age`. Only integration runs, and the image is still reused.

```console
$ hushrun plan
refresh: the artifacts are reusable: deploying image us-docker.pkg.dev/acme/api/server@sha256:3cae5d5c7887d7eb92e7e4a157ea3bc4d155b7d9aad6cd9f297c72d860f4b157 by digest; running integration (expired) first
  checks to run: integration
  image: us-docker.pkg.dev/acme/api/server@sha256:3cae5d5c7887d7eb92e7e4a157ea3bc4d155b7d9aad6cd9f297c72d860f4b157
  requirement lint                 satisfied
  requirement unit                 satisfied
  requirement integration          expired
```

`full`: the first run of new files.

```console
$ hushrun plan
full: no reusable `image` (none was recorded): building and running lint (missing), unit (missing), integration (missing)
  checks to run: lint unit integration
  requirement lint                 missing
  requirement unit                 missing
  requirement integration          missing
```

`current`: the repository already runs these files.

```console
$ hushrun plan
current: this repository already runs sha256:bad90344f9e5 (deployed at 2026-10-29T13:15:17.142Z); to deploy it again, re-run it or start it by hand
  requirement lint                 satisfied
  requirement unit                 satisfied
  requirement integration          satisfied
```

`blocked`: the run is on another branch.

```console
$ hushrun plan
blocked: this repository deploys only from main; this run is on fix-login, so run it from main
  requirement lint                 satisfied
  requirement unit                 missing
  requirement integration          missing
```

### Common situations

**A revert.** The files are ones an earlier attempt built and passed, so the plan is `promote`: it deploys that attempt's image again, by digest.

**A check failed.** The run records `failed`, and nothing it built is reused. Push a fix: the new files plan `full`. When the failure was flaky, re-run the workflow: it plans `full` too, and runs only the checks without a pass.

**A run that recorded no outcome**, such as one whose runner crashed. It counts as running until its lease ends, 3 hours by default. It never passed, so its image isn't reused, and the next run of those files builds again.

**The same files again.** A run plans `current` only on the first attempt of a run that a push or another workflow started. A run started by hand deploys again, and so does a re-run on GitHub Actions. On GitLab, a retried job plans `current` again, so run a new pipeline by hand.

**Forcing a full run.** On GitHub Actions, run the workflow by hand with `force_full`. On GitLab, run a pipeline with `HUSHRUN_FORCE_FULL=true`.

## Artifacts by digest

When a plan says `promote` or `refresh`, the deploy job runs `hushrun artifact resolve`. It verifies again the event that recorded the image, and outputs the reference to deploy, pinned by digest. It copies nothing: the image is where its build pushed it, at the artifact's `location`.

When the plan says `full`, `resolve` outputs where to push the build, `<location>:<revision>`, and `build=true`. After the push, `hushrun attest --artifact image=$REF` records the image by its digest.

- If the image's event doesn't verify any more, `resolve` falls back to a build, with a warning that says why. With `--no-fallback`, it exits 7 instead.
- A reused image keeps what its build baked in. A build-time value from outside the repository, such as a CI variable that Next.js inlines as `NEXT_PUBLIC_*` or Vite as `VITE_*`, isn't part of the digest. So changing only that value deploys the old image: force a full run. `hushrun doctor` looks for such values.
- To copy an image to another registry, `hushrun artifact copy` copies it by digest, with every platform. Registry credentials come from Docker's configuration, so `gcloud auth configure-docker` and `docker login` work as they are.

## Get started

With hushrun installed ([Quickstart](https://hushrun.com/docs/quickstart.md)):

1. Write a starter `hushrun.yaml` for the deploy:

   ```bash
   hushrun init --pipeline --image us-docker.pkg.dev/acme/api/server
   ```

   It writes a `deploy` block for `main`, with one `unit` check: `go test ./...`, `cargo test --locked`, `npm test` or `python -m pytest`, by what the repository looks like. `--branch` names another branch.
2. Run `npx hushrun init`. In a repository with a `hushrun.yaml`, it adds the repository's URL on hushrun and the log key, and keeps the rest of the file. An organization admin's or owner's init gives a repository its URL.
3. Add the pipeline to your CI. On GitHub Actions, `hushrun adopt` writes the workflow ([GitHub Actions](https://hushrun.com/docs/deploy/github.md)). On GitLab, use the pipeline in [GitLab CI](https://hushrun.com/docs/deploy/gitlab.md).
4. Run `hushrun doctor`, commit, and push to `main`.

> **Note:** `hushrun init --pipeline` never replaces a `hushrun.yaml` without `--force`. If `npx hushrun init` already wrote one, run `hushrun init --pipeline --force`, then `npx hushrun init` again: it adds the URL and the log key back.

## Next

- [GitHub Actions](https://hushrun.com/docs/deploy/github.md): set up the deploy with hushrun's reusable workflow.
- [GitLab CI](https://hushrun.com/docs/deploy/gitlab.md): the same deploy on GitLab.
- [Evidence from agents and laptops](https://hushrun.com/docs/deploy/evidence.md): let agents run the deploy's checks before a push.
- [Transparency log](https://hushrun.com/docs/deploy/transparency.md): how every command checks hushrun's server.
