Skip to content

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 wantUse
CI, laptops and agents to skip checks that already passedhushrun run alone
Each environment to deploy the artifact the one before it validatedPromotion
A person's approval before a deployPromotion
A stage that counts only CI's evidencePromotion
A signed record of every check, build and deployPromotion

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 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: its 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/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:

hushrun.yaml
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 plans blocked.
  • after: the upstream stage. This stage reuses its artifacts, and doesn't deploy a digest it failed.
  • require lists what must hold before the stage deploys:
    • Source checks (kind: source) depend only on the content, so evidence recorded at any stage counts. With max_age, such as 7d, 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 as e2e@staging.
    • Approvals (kind: approval) are a person's sign-off, with no run: see Approvals.
  • artifacts names 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 --stage or hushrun attest --check;
  • artifacts, from hushrun attest --artifact, or the copy hushrun artifact resolve makes;
  • each attempt's lifecycle, started, deployed, passed, failed or superseded, from hushrun plan --start, hushrun mark and hushrun guard;
  • approvals, from hushrun approve or 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 login registers 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 NAME or 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: write signs 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:

ModeWhat the pipeline does
promoteDeploys the artifact an earlier stage built and validated, copied by digest
refreshRuns only the checks whose evidence is missing, expired or failed, then deploys the reused artifact
fullBuilds, runs the checks that lack valid evidence, and deploys
waitNothing yet: an upstream run for this digest is still going, and its completion plans again
skipNothing: this stage already runs this digest
blockedNothing, 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, or HUSHRUN_FORCE_FULL=true, builds and runs every source check whatever the evidence. It never waives an approval, and a stage with allow_force: false refuses it.
  • hushrun status shows 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>:

  1. It verifies again the event that recorded the upstream's image.
  2. 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.
  3. 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, resolve falls 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-docker and docker login work as they are.

Get started

With hushrun installed (Quickstart):

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

    hushrun init --pipeline \
      --staging-image us-docker.pkg.dev/acme-staging/api/server \
      --production-image us-docker.pkg.dev/acme-prod/api/server

    It writes two stages, staging from develop and production from main, with one unit check: go test ./..., cargo test --locked, npm test or python -m pytest, by what the repository looks like. --staging-branch and --production-branch name other branches.

  2. Run npx hushrun init. In a repository with a hushrun.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.

  3. Add the pipeline to your CI: on GitHub Actions, hushrun adopt writes two workflows that call hushrun's reusable workflow (GitHub Actions); on GitLab, use the pipeline in GitLab CI.

  4. Run hushrun doctor, commit, and push to develop.

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.

Markdown