Skip to content

Deploys

Updated

This page explains deploying with hushrun. Before any work, the deploy asks hushrun's 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 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 wantUse
CI, laptops and agents to skip checks that already passedhushrun run -- COMMAND alone
A deploy that builds and checks the same files onceA deploy pipeline
A signed record of every check, build and deployA 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:

$ hushrun digest --rev main~2
sha256:bad90344…28f8  default
$ hushrun digest --rev main
sha256:bad90344…28f8  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.
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:

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

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.

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:

$ hushrun verify --scope default --digest sha256:bad90344…28f8
ok        v1/events/default/bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8/01M63WSKM1RC1PA3Y6HHBTFNFC.started._.json: started signed by ci (custody ci) for sha256:bad90344…28f8, 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:bad90344…28f8, 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.
  • An agent of the organization. Every agent token comes with a machine key of its own. See Evidence from 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.

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

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

ModeDeploys?The CI jobWhat you do
promoteYes: the image an earlier attempt built and checkedGreenNothing
refreshYes: that image, once the checks to run passGreen, or red if a check failsNothing, or fix the check
fullYes: a new build, once the checks to run passGreen, or red if a check failsNothing, or fix the check
currentNo: the repository already runs these filesGreen, with the deploy skippedNothing. To deploy them again, re-run it or start it by hand
blockedNoRed: hushrun plan exits 3, which fails the job on purposeWhat 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.

$ hushrun plan
promote: every required check is met: deploying image us-docker.pkg.dev/acme/api/server@sha256:3cae5d5c…b157 by digest
  image: us-docker.pkg.dev/acme/api/server@sha256:3cae5d5c…b157
  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.

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

full: the first run of new files.

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

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

$ 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):

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

    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). On GitLab, use the pipeline in GitLab CI.

  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

Markdown