# Promotion on GitHub Actions

This page sets up a repository that deploys staging from `develop` and production from `main`, so that production deploys the image staging built and validated, copied by digest. For what promotion is, start at [Promotion](https://hushrun.com/docs/promotion.md).

## What you need

- Admin rights on the repository, and its default branch set to the production branch, `main`. GitHub runs `workflow_run` workflows only from the default branch, and production's workflow is started by staging's. `hushrun doctor` checks it.
- An org on hushrun ([sign up](https://app.hushrun.com/sign-up)), and the repository added to it: an org admin's `npx hushrun init` adds it.
- The container registries your images already use.
- A build that bakes in no per-environment configuration: see [When you need it](https://hushrun.com/docs/promotion.md#when).
- hushrun installed: see [Quickstart](https://hushrun.com/docs/quickstart.md).

## Set it up

1. Write the starter `hushrun.yaml`:

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

2. Edit `hushrun.yaml`: the checks each stage requires and their commands, the scopes of a monorepo, `digest.exclude`, and the stages ([Promotion](https://hushrun.com/docs/promotion.md#stages)). In a monorepo, each scope includes `.github/workflows/deploy-*.yml` and `hushrun.yaml`, so that a change to the deploy changes the digest. Keep `ci: {github: {workflow: deploy-staging.yml}}` on staging: it lets production see staging runs that are queued but haven't recorded anything yet. Then check the file:

   ```console
   $ hushrun policy validate
   hushrun.yaml is valid: 1 scope(s), 3 check(s), 1 artifact(s), 2 stage(s)
   ```

3. Run `npx hushrun init`. It adds `repository_url` and `transparency_log` to `hushrun.yaml`. With the GitHub CLI (`gh`) signed in, it also tells hushrun the repository's GitHub id, which is how hushrun's server recognizes the repository's CI; without `gh`, the server learns the id from the repository's first CI run. Commit `hushrun.yaml`: hushrun reads it as committed.
4. Write the workflows:

   ```bash
   hushrun adopt
   ```

   It writes `.github/workflows/deploy-staging.yml` and `deploy-production.yml`, two short workflows that call hushrun's reusable workflow, and keeps any file that already differs from what it would write (`--dry-run` prints what it would write, `--force` replaces). In each workflow, set `build`, `deploy` and your cloud credentials, and pin the reusable workflow to a release commit: see [The reusable workflow](#reusable-workflow).
5. Check the setup with `hushrun doctor` ([below](#doctor)).
6. Push to `develop`. Staging plans `full`: it runs the checks, builds, deploys, and records each step. Merge to `main`. Production plans `promote`: it copies staging's image to the production registry by digest and deploys it, with no build and no checks to repeat. Each plan job's step summary shows the decision and why.

## Keyless signing

Give every job that runs hushrun `permissions: id-token: write`. With GitHub's ID token, the job authenticates to hushrun's server and signs its events keylessly, as the repository's CI. No secret is needed, and no key.

- The job asks GitHub for an ID token, Sigstore's certificate authority (Fulcio) certifies a key that lives only in the job, and a timestamp authority timestamps each signature.
- The certificate names the repository's numeric id. hushrun's server names GitHub Actions with that id as the repository's CI, so the events count as `ci`.
- Any workflow of the repository signs as its CI, except a run that `pull_request_target` started, which runs the base branch's workflow for a change that hasn't merged.
- In CI, hushrun checks that the server's CI is this job's repository, and exits 2 otherwise: a `repository_url` that points at another repository fails loudly.
- `HUSHRUN_SIGNING=sigstore` is the default in a job with an ID token and no key; the reusable workflow sets it with `signing: sigstore`.
- When Fulcio or the timestamp authority can't be reached, a signing step exits 9 and records nothing: re-run the job. Verifying needs neither. It is offline, against the Sigstore trust root built into hushrun, or a newer one that `hushrun sigstore refresh` fetches and caches.

Public-good Sigstore logs every certificate it issues in a public log, with the repository's and the workflow's names. For a private repository whose names must stay out of it, name a private Sigstore instance with `HUSHRUN_FULCIO_URL`, `HUSHRUN_TSA` and `HUSHRUN_SIGSTORE_TRUSTED_ROOT`, or sign with an agent's machine key ([Evidence from agents](https://hushrun.com/docs/promotion/evidence.md#agents)). Events signed that way are the agent's, which a CI-only stage doesn't count.

### The repository's URL in CI

- In CI, hushrun reads the repository's URL from the `repository_url` of the committed `hushrun.yaml`, and sends it only the job's ID token, whose audience is that URL. No variable is needed.
- To set it outside the repository too, add a repository variable `HUSHRUN_REPOSITORY_URL` (Settings, Secrets and variables, Actions, Variables). `hushrun adopt`'s workflows pass it, empty when unset. When it is set, it must match `hushrun.yaml`'s, or every command that reads or writes events exits 2.
- `HUSHRUN_REPOSITORY_TOKEN` goes only to a URL that `HUSHRUN_REPOSITORY_URL` or `--repository-url` names, never to one only `hushrun.yaml` names, so a change to `hushrun.yaml` can't send a secret elsewhere.

## The plan and the jobs after it

The plan job decides once, and every later job carries out its plan:

- **plan**: `hushrun plan --stage staging --start` decides, records `started` for the scope it deploys, writes its outputs to `$GITHUB_OUTPUT` (`mode`, `reason`, `digest`, `revision`, `attempt`, `build`, `ref`, `artifacts`, `checks`, `deployment_checks`, and the whole plan as `json`), and writes a step summary with the mode and the reason. It exits 3 when the plan is `blocked`, which fails the workflow on purpose. With several scopes, `actionable_scopes` lists the ones that deploy, in place of the per-scope outputs.
- **test**: `hushrun run --stage staging` runs exactly the plan's checks, and records each result. It exits 8 when a check fails.
- **deploy**: runs when the mode is `promote`, `refresh` or `full`:
  1. `hushrun guard --stage staging` stops a deploy of a revision that is no longer the branch's head ([Guard](#guard)).
  2. `hushrun artifact resolve --stage staging` outputs `ref`, the image to deploy, and `build`. In `promote` and `refresh`, it copies the upstream's image by digest when needed.
  3. When `build` is `true`: your build pushes the image to `$REF`, then `hushrun attest --stage staging --artifact image=$REF` records it, pinned by digest.
  4. Your deploy deploys `$REF`, then `hushrun mark deployed --stage staging --artifact image=$REF`.
  5. `hushrun run <checks> --stage staging` runs the deployment checks other stages require of this one, the plan's `deployment_checks`.
  6. `hushrun mark passed --stage staging --artifact image=$REF`.
- **report**: when the run is cancelled, `hushrun mark superseded --stage staging --reason cancelled`; when a job failed or timed out, `hushrun mark failed --stage staging --reason "…"`.

Each later job gets the plan as `HUSHRUN_PLAN_JSON`, from the plan job's `json` output, or as a file with `--plan plan.json`. The plan job, written by hand:

```yaml title=".github/workflows/deploy-staging.yml"
jobs:
  plan:
    runs-on: ubuntu-latest
    permissions: { contents: read, actions: read, id-token: write }
    outputs:
      mode: ${{ steps.plan.outputs.mode }}
      checks: ${{ steps.plan.outputs.checks }}
      deployment_checks: ${{ steps.plan.outputs.deployment_checks }}
      json: ${{ steps.plan.outputs.json }}
    steps:
      - uses: actions/checkout@v4
      - uses: hushrun/hushrun/action/setup@main   # pin to a release commit
        with: { version: 0.1.0 }
      - id: plan
        run: hushrun plan --stage staging --start
        env:
          GITHUB_TOKEN: ${{ github.token }}   # lets the plan ask GitHub about staging's runs
```

The plan asks GitHub whether upstream runs are queued, running or finished, which needs `actions: read` and `GITHUB_TOKEN`. Every job checks out the revision the plan was made for: never check out `ref: main` or pass `--rev`.

## Guard

A newer commit can land on the branch while a deploy runs. `hushrun guard --stage <stage>` checks that the revision being deployed is still the head of the stage's branch on `origin`:

- It is: `guard` outputs `stale=false` and exits 0.
- The branch moved on: `guard` records `superseded`, outputs `stale=true`, and exits 0. The deploy steps after it skip on `steps.guard.outputs.stale == 'true'`, and the newer run deploys instead.
- `--on-stale fail` exits 4 instead of 0. `--branch` and `--remote` name another branch or remote.

## Pull requests

A job on `pull_request` reads `hushrun.yaml` from the branch the pull request merges into, which was reviewed, never from the change. Fetch that branch, and name it with `--config-ref`:

```bash
git fetch --depth=1 origin "$GITHUB_BASE_REF"
hushrun plan --stage pr --config-ref "origin/$GITHUB_BASE_REF"
```

Without `--config-ref`, or with another branch, every command that reads or writes events refuses, and exits 2.

- A pull request's jobs sign as the repository's CI too. Give their plans a stage of their own, such as a `pr` stage with no `branch` that requires the source checks, and deploy only from the stage branches.
- Source checks a pull request's jobs pass count at every stage for the same digest. When the base branch hasn't moved, the merged commit has the same files, and staging doesn't run those checks again.

## The reusable workflow

`hushrun adopt` writes one short workflow per stage, each calling hushrun's reusable workflow, which holds the plan, test, deploy and report jobs above. A fix to those jobs reaches your repository as a new commit of the reusable workflow. Production's, with its values set:

```yaml title=".github/workflows/deploy-production.yml"
name: Deploy to Production
on:
  push:
    branches: [main]
  workflow_run:                       # staging finished: plan again
    workflows: ["Deploy to Staging"]
    types: [completed]
    branches: [develop]
  workflow_dispatch:
    inputs:
      force_full:
        description: Build and run every check, whatever the evidence
        type: boolean
        default: false

# One deploy at a time; runs started off main get a group of their own.
concurrency:
  group: ${{ github.ref == 'refs/heads/main' && 'deploy-production' || format('deploy-production-refused-{0}', github.run_id) }}
  cancel-in-progress: false

permissions:
  contents: read
  actions: read            # the plan asks GitHub about earlier runs
  id-token: write          # workload identity federation; ID tokens for hushrun's server

jobs:
  deploy:
    uses: hushrun/hushrun/.github/workflows/hushrun-deploy.yml@main   # pin to a release commit
    with:
      stage: production
      version: 0.1.0
      repository-base: ${{ vars.HUSHRUN_REPOSITORY_BASE }}
      repository-url: ${{ vars.HUSHRUN_REPOSITORY_URL }}
      force-full: ${{ inputs.force_full || false }}
      toolchain: node
      setup: gcloud auth configure-docker us-docker.pkg.dev --quiet
      build: docker build -t "$REF" . && docker push "$REF"
      deploy: gcloud run deploy api --image "$REF" --region us-central1
      gcp-workload-identity-provider: projects/123456789/locations/global/workloadIdentityPools/github/providers/github
      gcp-service-account: deployer@acme-prod.iam.gserviceaccount.com
      signing: sigstore        # keylessly, as this repository's CI
      deploy-environment: production
```

Its inputs:

| Input | What it is |
|---|---|
| `stage` | The stage to deploy, as `hushrun.yaml` names it. Required |
| `version` | The exact hushrun release to install, such as `0.1.0`. Required |
| `deploy` | The deploy command. `$REF` is the image, pinned by digest. Required |
| `build` | The build command, run when the plan builds. It pushes the image to `$REF` |
| `setup` | Commands every job runs after its credentials, such as `gcloud auth configure-docker` |
| `image` | The artifact of `hushrun.yaml` that `build` and `deploy` work on. Default `image` |
| `scope` | The scope to deploy, in a repository with several. Default its only one |
| `force-full` | Build and run every check, whatever the evidence |
| `isolated` | Run the plan's checks in their pinned images with Docker, as agents run them ([pinned images](https://hushrun.com/docs/promotion/evidence.md#images)) |
| `toolchain`, `toolchain-version` | The toolchain the checks need, `go`, `node`, `python`, `java` or `none`, and its version |
| `runs-on` | The runner of every job. Default `ubuntu-latest` |
| `signing` | `sigstore` to sign keylessly, as the repository's CI |
| `deploy-environment` | The GitHub environment of the deploy job |
| `signing-environment` | The GitHub environment of the jobs that sign |
| `repository-url`, `repository-base` | The repository's URL from your settings. Empty, hushrun reads `hushrun.yaml`'s |
| `gcp-workload-identity-provider`, `gcp-service-account` | Google Cloud credentials through workload identity federation |
| `aws-role-to-assume`, `aws-region` | AWS credentials through GitHub's OIDC provider |
| `azure-client-id`, `azure-tenant-id`, `azure-subscription-id` | Azure credentials through a federated credential |

Its outputs are `mode`, the plan's mode, and `ref`, the image deployed. No job needs a cloud secret: credentials come through your cloud's workload identity federation with GitHub's ID token.

### A monorepo

Call the reusable workflow once per scope. Each plans its own digest, so a scope whose files didn't change skips while the others deploy. `build` and `deploy` see the scope as `$SCOPE`:

```yaml title=".github/workflows/deploy-staging.yml"
jobs:
  deploy:
    strategy:
      fail-fast: false
      matrix:
        scope: [api, web]
    uses: hushrun/hushrun/.github/workflows/hushrun-deploy.yml@main   # pin to a release commit
    with:
      stage: staging
      scope: ${{ matrix.scope }}
      image: ${{ matrix.scope }}-image
      version: 0.1.0
      build: docker build -t "$REF" "services/$SCOPE" && docker push "$REF"
      deploy: gcloud run deploy "$SCOPE" --image "$REF" --region us-central1
      signing: sigstore
```

## Check the setup

```bash
hushrun doctor
```

`doctor` exits 2 when a check fails. It checks:

- git, and the CI it runs in;
- `hushrun.yaml`;
- hushrun's server: that it is reachable, speaks hushrun's protocol and holds this repository, and how far this machine's clock is from it;
- the transparency log, against `hushrun.yaml`'s `transparency_log`;
- who counts, and, with `HUSHRUN_SIGNING` or a key set, whether this job or machine signs as someone who counts, and whether the timestamp authority answers;
- read access to the registries `hushrun.yaml` names;
- that every scope covers the workflows that run hushrun;
- configuration baked into the build, such as `NEXT_PUBLIC_*` or `VITE_*`;
- with `GITHUB_TOKEN` (or `GH_TOKEN`) and `GITHUB_REPOSITORY` set, that the default branch is the branch of the stages an upstream starts.

## Day to day

- **A plan says `wait`.** Staging is still running this digest. Production plans again when staging's workflow completes, through `workflow_run`: nothing to do.
- **A plan says `blocked`.** The reason names the run and what to do, usually "fix forward, or force".
- **Forcing a full build.** Run the production workflow by hand with `force_full`. A forced run rebuilds and runs every source check, but never waives an approval, and a stage with `allow_force: false` refuses it.
- **A plan says `refresh`.** Evidence expired (`max_age`), or a check's definition changed. Only those checks run, and the image is still reused.
- **A newer commit landed while deploying.** `guard` records `superseded`, and the deploy steps skip. The newer run deploys instead.
- **Re-running failed jobs.** A re-run is a new attempt of the same run, and hushrun records it as one.
- **Old events.** An org admin runs `hushrun repository gc --older-than 180d --dry-run` to see what would go, then without `--dry-run`. It keeps the newest deployment of each scope and stage.
- **The org's plan is used up.** New check results aren't recorded, and `run` and `attest` say so once, with where to upgrade. Everything else is recorded, and deploys go on: see [Plans and limits](https://hushrun.com/docs/plans.md#at-the-limit).

## Exit codes

The ones a pipeline meets most, from [the full list](https://hushrun.com/docs/reference/cli.md#exit-codes):

| Code | Meaning |
|---|---|
| 2 | A usage or configuration error, such as a `repository_url` that doesn't match, or a pull request without `--config-ref` |
| 3 | `plan`: blocked |
| 4 | `guard --on-stale fail`: the branch moved on |
| 6 | hushrun's server is unavailable or refused access, or a transparency check failed |
| 7 | `artifact resolve` couldn't copy the image and may not fall back to a build |
| 8 | `run --stage`: a check failed, and its result was recorded |
| 9 | The signer is unavailable: GitHub's ID token, Fulcio, or the timestamp authority |
