# 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`](https://hushrun.com/docs/run.md) 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:

```console
$ hushrun digest --rev develop
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: 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.

```yaml title="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:

```yaml title="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](https://hushrun.com/docs/promotion/evidence.md#approvals).
- `artifacts` names each image and its registry repository at every stage.

Every key is in [Configuration](https://hushrun.com/docs/reference/configuration.md).

## 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:

```console
$ hushrun verify --scope default --digest sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8
ok        v1/events/default/bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8/01M63WSKM1RC1PA3Y6HHBTFNFC.started.staging.json: started at staging signed by ci (custody ci) for sha256:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8, 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:bad90344f9e5f7d8a3f4a46a99b40eede644f97d1dc93de3ce59c7cbb9ee28f8, 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](https://hushrun.com/docs/teams.md#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](https://hushrun.com/docs/promotion/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/promotion/github.md#keyless).

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](https://hushrun.com/docs/promotion/transparency.md)). `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.

```console
$ hushrun plan --stage production
default promote: every requirement of `production` is met: deploying image us-docker.pkg.dev/acme-staging/api/server@sha256:3cae5d5c7887d7eb92e7e4a157ea3bc4d155b7d9aad6cd9f297c72d860f4b157 by digest
  image: us-docker.pkg.dev/acme-staging/api/server@sha256:3cae5d5c7887d7eb92e7e4a157ea3bc4d155b7d9aad6cd9f297c72d860f4b157
  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.

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

`full`: staging's first run of new content.

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

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

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

```console
$ 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](https://hushrun.com/docs/quickstart.md)):

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

   ```bash
   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](https://hushrun.com/docs/promotion/github.md)); on GitLab, use the pipeline in [GitLab CI](https://hushrun.com/docs/promotion/gitlab.md).
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](https://hushrun.com/docs/promotion/evidence.md).
