Promotion
Promotion turns hushrun into a deploy pipeline: CI builds and validates each change once, and every later stage deploys that exact artifact, copied by digest.
What promotion adds
hushrun run -- COMMAND skips a check this exact code already passed. Its results decide only whether a check runs again.
Promotion adds stages, such as staging and production, and a plan for each:
- Before any work, each stage asks hushrun's planner what is left to do for this content.
- Staging builds the image, runs the checks, and deploys.
- Production deploys the image staging built and validated, copied to production's registry by digest. It rebuilds nothing and repeats no check.
- Each step is recorded as a signed event on hushrun's server, so every decision can be checked later.
When you need it
| You want | Use |
|---|---|
| CI, laptops and agents to skip checks that already passed | hushrun run alone |
| Each environment to deploy the artifact the one before it validated | Promotion |
| A person's approval before a deploy | Promotion |
| A stage that counts only CI's evidence | Promotion |
| A signed record of every check, build and deploy | Promotion |
You can use both. They don't share results: a plan counts only evidence recorded for a stage, by hushrun run --stage or hushrun attest, never the results hushrun run -- COMMAND keeps.
Promotion asks one thing of your build: it bakes in no per-environment configuration. An image promoted to production runs there unchanged, so build-time values such as Next.js NEXT_PUBLIC_* or Vite VITE_* would carry staging's settings into production. hushrun doctor looks for them.
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 main's merge commit has the files develop had:
$ hushrun digest --rev develop 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: its 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/deploy-*.yml", "hushrun.yaml"]
web:
include: ["services/web/**", ".github/workflows/deploy-*.yml", "hushrun.yaml"]
digest:
exclude: ["**/*.md"]Stages and requirements
A stage names its branch, the stage it comes after, and what it requires before it deploys. The whole hushrun.yaml of a two-stage repository:
version: 1
repository: github.com/acme/api
checks:
lint: { kind: source, run: pnpm lint }
unit: { kind: source, run: pnpm test, timeout: 20m }
integration: { kind: source, run: pnpm test:integration, timeout: 30m }
artifacts:
image:
type: oci
locations:
staging: us-docker.pkg.dev/acme-staging/api/server
production: us-docker.pkg.dev/acme-prod/api/server
stages:
staging:
branch: develop
require: [lint, unit, integration]
production:
branch: main
after: staging
require: [lint, unit, integration]branch: the stage deploys only from it. A run on another branch plansblocked.after: the upstream stage. This stage reuses its artifacts, and doesn't deploy a digest it failed.requirelists what must hold before the stage deploys:- Source checks (
kind: source) depend only on the content, so evidence recorded at any stage counts. Withmax_age, such as7d, a check runs again once its evidence is older. - Deployment checks (
kind: deployment) run against a stage's deployment. A later stage requires them as<check>@<stage>, such ase2e@staging. - Approvals (
kind: approval) are a person's sign-off, with norun: see Approvals.
- Source checks (
artifactsnames each image and its registry repository at every stage.
Every key is in Configuration.
Evidence
Each step of a pipeline records what it did as a signed event on hushrun's server:
- check results, from
hushrun run --stageorhushrun attest --check; - artifacts, from
hushrun attest --artifact, or the copyhushrun artifact resolvemakes; - each attempt's lifecycle,
started,deployed,passed,failedorsuperseded, fromhushrun plan --start,hushrun markandhushrun guard; - approvals, from
hushrun approveor the dashboard.
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 org 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.staging.json: started at staging signed by ci (custody ci) for sha256:bad90344…28f8, with identity https://github.com/acme/api/.github/workflows/deploy-staging.yml@refs/heads/develop, timestamped at 2026-10-29T13:15:08.802Z ok v1/events/default/bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8/01M63WSKS5VZAK8574WJTKDWS8.check.staging.json: check at staging signed by ci (custody ci) for sha256:bad90344…28f8, with identity https://github.com/acme/api/.github/workflows/deploy-staging.yml@refs/heads/develop, timestamped at 2026-10-29T13:15:08.966Z …
Who counts
Nobody keeps a list of signers. An event counts when one of these signed it:
- A member's machine.
hushrun loginregisters the machine it runs on, and its events count while its owner is a member of the repository's org. See Machines. - An agent of the org. Every agent token, made with
hushrun token create --agent NAMEor on the dashboard's Agents page, 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, and signs its answer with the log key hushrun.yaml pins, so it can't change the answer silently (Transparency log). hushrun repository signers prints that answer.
Removing someone stops the future, not the past. When a member leaves the org 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-only stages
A stage such as production can take evidence from the repository's CI only. An org admin sets it in the dashboard:
- for every repository of the org, on the org's Settings page;
- for one repository, on its repository page.
It is never in hushrun.yaml, so no pull request can drop it. At a CI-only stage:
- checks, artifacts and lifecycle events count only when the repository's CI signed them;
- an event someone else signed counts there only when its trusted timestamp falls outside every period the stage was CI-only: evidence made before the rule keeps counting, and evidence made while it held never does, even after it is lifted;
- hushrun's server refuses a member's or an agent's write to the stage, other than an approval.
Approvals count only from people, at every stage: a member, from a machine they signed in on or from the dashboard. Never from an agent or the CI, so no pipeline or agent approves its own deploy. hushrun policy explain --stage production shows whether the server makes the stage CI-only, since when, and whose events count for each requirement.
The planner
A pipeline's first job runs hushrun plan --stage <stage>, and every later job carries that plan out instead of deciding again. Each plan ends in one of six modes:
| Mode | What the pipeline does |
|---|---|
promote | Deploys the artifact an earlier stage built and validated, copied by digest |
refresh | Runs only the checks whose evidence is missing, expired or failed, then deploys the reused artifact |
full | Builds, runs the checks that lack valid evidence, and deploys |
wait | Nothing yet: an upstream run for this digest is still going, and its completion plans again |
skip | Nothing: this stage already runs this digest |
blocked | Nothing, and the job fails with exit code 3: the upstream failed this digest, the run is on another branch than the stage's, or evidence is missing that this stage can't produce, such as an approval |
Here is what each mode printed for that repository, whose develop (0e5399f) and main (6083e16) have the same files. For refresh, its integration check had a max_age.
promote: staging passed this digest, so production deploys staging's image.
$ hushrun plan --stage production default promote: every requirement of `production` is met: deploying image us-docker.pkg.dev/acme-staging/api/server@sha256:3cae5d5c…b157 by digest image: us-docker.pkg.dev/acme-staging/api/server@sha256:3cae5d5c…b157 requirement lint satisfied requirement unit satisfied requirement integration satisfied
refresh: integration's evidence is older than its max_age, so only integration runs, and the image is still reused.
$ hushrun plan --stage production default refresh: the artifacts are reusable: deploying image us-docker.pkg.dev/acme-staging/api/server@sha256:3cae5d5c…b157 by digest; running integration (expired) first checks to run: integration image: us-docker.pkg.dev/acme-staging/api/server@sha256:3cae5d5c…b157 requirement lint satisfied requirement unit satisfied requirement integration expired
full: staging's first run of new content.
$ hushrun plan --stage staging default 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
wait: staging is still running this digest.
$ hushrun plan --stage production default wait: `staging` is still running sha256:bad90344f9e5 in github run 201 attempt 1 (https://github.com/acme/api/actions/runs/201/attempts/1); its completion triggers a new plan here requirement lint missing requirement unit missing requirement integration missing
skip: production already runs this digest.
$ hushrun plan --stage production default skip: `production` already runs sha256:bad90344f9e5 (deployed at 2026-10-29T13:15:17.142Z); re-run, or run manually, to deploy it again requirement lint satisfied requirement unit satisfied requirement integration satisfied
blocked: staging failed this digest.
$ hushrun plan --stage production default blocked: `staging` failed sha256:bad90344f9e5 in github run 201 attempt 1 (https://github.com/acme/api/actions/runs/201/attempts/1); fix forward, or force requirement lint satisfied requirement unit failed requirement integration missing
--force-full, orHUSHRUN_FORCE_FULL=true, builds and runs every source check whatever the evidence. It never waives an approval, and a stage withallow_force: falserefuses it.hushrun statusshows each stage's mode and requirements for the current digest, without recording anything.
Artifacts by digest
When a plan says promote or refresh, the deploy job runs hushrun artifact resolve --stage <stage>:
- It verifies again the event that recorded the upstream's image.
- When this stage's registry repository is another one, it copies the image there by digest: an index with every platform, and a mount instead of a copy on the same registry host. It tags the copy with the revision, and records it.
- It outputs the reference to deploy, pinned by digest.
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 copy fails,
resolvefalls back to a build, with a warning that says why. For Artifact Registry, Amazon ECR, Azure Container Registry and GitHub's registry, a refused copy's warning names the permissions it needs. - With
--no-fallback, or at a stage that requires deployment checks, which a rebuilt image never passed, it exits 7 instead. - Registry credentials come from Docker's configuration, so
gcloud auth configure-dockeranddocker loginwork as they are.
Get started
With hushrun installed (Quickstart):
Write a starter
hushrun.yamlfor the pipeline:hushrun init --pipeline \ --staging-image us-docker.pkg.dev/acme-staging/api/server \ --production-image us-docker.pkg.dev/acme-prod/api/serverIt writes two stages,
stagingfromdevelopandproductionfrommain, with oneunitcheck:go test ./...,cargo test --locked,npm testorpython -m pytest, by what the repository looks like.--staging-branchand--production-branchname other branches.Run
npx hushrun init. In a repository with ahushrun.yaml, it adds the repository's URL on hushrun and the server's log key, and keeps the rest of the file. A repository gets its URL from an org admin's init.Add the pipeline to your CI: on GitHub Actions,
hushrun adoptwrites two workflows that call hushrun's reusable workflow (GitHub Actions); on GitLab, use the pipeline in GitLab CI.Run
hushrun doctor, commit, and push todevelop.
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.
Then let agents and laptops record evidence CI's plan counts, and people approve deploys: Evidence from agents and laptops.