Skip to content

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.

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), 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.
  • hushrun installed: see Quickstart.

Set it up

  1. Write the starter hushrun.yaml:

    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). 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:

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

    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.

  5. Check the setup with hushrun doctor (below).

  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). 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).
    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:

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

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:

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

InputWhat it is
stageThe stage to deploy, as hushrun.yaml names it. Required
versionThe exact hushrun release to install, such as 0.1.0. Required
deployThe deploy command. $REF is the image, pinned by digest. Required
buildThe build command, run when the plan builds. It pushes the image to $REF
setupCommands every job runs after its credentials, such as gcloud auth configure-docker
imageThe artifact of hushrun.yaml that build and deploy work on. Default image
scopeThe scope to deploy, in a repository with several. Default its only one
force-fullBuild and run every check, whatever the evidence
isolatedRun the plan's checks in their pinned images with Docker, as agents run them (pinned images)
toolchain, toolchain-versionThe toolchain the checks need, go, node, python, java or none, and its version
runs-onThe runner of every job. Default ubuntu-latest
signingsigstore to sign keylessly, as the repository's CI
deploy-environmentThe GitHub environment of the deploy job
signing-environmentThe GitHub environment of the jobs that sign
repository-url, repository-baseThe repository's URL from your settings. Empty, hushrun reads hushrun.yaml's
gcp-workload-identity-provider, gcp-service-accountGoogle Cloud credentials through workload identity federation
aws-role-to-assume, aws-regionAWS credentials through GitHub's OIDC provider
azure-client-id, azure-tenant-id, azure-subscription-idAzure 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:

.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

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.

Exit codes

The ones a pipeline meets most, from the full list:

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