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 want | Use |
|---|---|
| CI, laptops and agents to skip checks that already passed | hushrun run - 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:
$ hushrun digest --rev main~2 sha256:bad90344…28f8 default $ hushrun digest --rev main sha256:bad90344…28f8 default
- Without
scopesinhushrun.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.excludeleaves out files that never affect what ships, such as**/*.md. A commit that changes only those deploys nothing new.hushrun digest --explainlists every file, with the pattern that decided its scope.
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:
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 arunwhen the file doesn't list them. A check depends only on the files, so a pass recorded on any machine that counts satisfies it. Withmax_age, such as7d, older evidence has expired.artifacts: each image, and the registry repository its build pushes to and the deploy takes it from.lease,dedupe,allow_forceandon_repository_errorare 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.yamlwritten before hushrun 0.9.0 may still havestages, a check'skindor an artifact'slocations.hushrun run,hushrun readyand the pre-push hook read past them with a warning that names the line, and keep skipping.hushrun planrefuses the file, since a deploy that ignoredstagescould skip what they required. Move a stage'sbranch,requireandleaseunderdeploy, give each artifact onelocation, 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 CHECKorhushrun attest --check; - artifacts, from
hushrun attest --artifact; - each attempt's lifecycle (
started,deployed,passed,failedorsuperseded), fromhushrun plan --start,hushrun markandhushrun 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 loginregisters 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: writesigns 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 readyleaves 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:
- Is the run on the deploy's branch? If not:
blocked. Run it fromdeploy.branch. - Is the run forced? With
--force-full, orHUSHRUN_FORCE_FULL=true:full. It builds and runs every required check, whatever the evidence. Withallow_force: falseindeploy, a forced run isblockedinstead. - Does the repository already run these files? On the first attempt of a run that a push or another workflow started (the
dedupetriggers):current. There's nothing to deploy. - 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. - 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.
$ 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,
resolvefalls 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 asVITE_*, isn't part of the digest. So changing only that value deploys the old image: force a full run.hushrun doctorlooks for such values. - To copy an image to another registry,
hushrun artifact copycopies it by digest, with every platform. Registry credentials come from Docker's configuration, sogcloud auth configure-dockeranddocker loginwork as they are.
Get started
With hushrun installed (Quickstart):
Write a starter
hushrun.yamlfor the deploy:hushrun init --pipeline --image us-docker.pkg.dev/acme/api/serverIt writes a
deployblock formain, with oneunitcheck:go test ./...,cargo test --locked,npm testorpython -m pytest, by what the repository looks like.--branchnames another branch.Run
npx hushrun init. In a repository with ahushrun.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.Add the pipeline to your CI. On GitHub Actions,
hushrun adoptwrites the workflow (GitHub Actions). On GitLab, use the pipeline in GitLab CI.Run
hushrun doctor, commit, and push tomain.
Note:
hushrun init --pipelinenever replaces ahushrun.yamlwithout--force. Ifnpx hushrun initalready wrote one, runhushrun init --pipeline --force, thennpx hushrun initagain: it adds the URL and the log key back.
Next
- GitHub Actions: set up the deploy with hushrun's reusable workflow.
- GitLab CI: the same deploy on GitLab.
- Evidence from agents and laptops: let agents run the deploy's checks before a push.
- Transparency log: how every command checks hushrun's server.