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 runsworkflow_runworkflows only from the default branch, and production's workflow is started by staging's.hushrun doctorchecks it. - An org on hushrun (sign up), and the repository added to it: an org admin's
npx hushrun initadds 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
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/serverEdit
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-*.ymlandhushrun.yaml, so that a change to the deploy changes the digest. Keepci: {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)
Run
npx hushrun init. It addsrepository_urlandtransparency_logtohushrun.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; withoutgh, the server learns the id from the repository's first CI run. Commithushrun.yaml: hushrun reads it as committed.Write the workflows:
hushrun adoptIt writes
.github/workflows/deploy-staging.ymlanddeploy-production.yml, two short workflows that call hushrun's reusable workflow, and keeps any file that already differs from what it would write (--dry-runprints what it would write,--forcereplaces). In each workflow, setbuild,deployand your cloud credentials, and pin the reusable workflow to a release commit: see The reusable workflow.Check the setup with
hushrun doctor(below).Push to
develop. Staging plansfull: it runs the checks, builds, deploys, and records each step. Merge tomain. Production planspromote: 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_targetstarted, 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_urlthat points at another repository fails loudly. HUSHRUN_SIGNING=sigstoreis the default in a job with an ID token and no key; the reusable workflow sets it withsigning: 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 refreshfetches 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_urlof the committedhushrun.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 matchhushrun.yaml's, or every command that reads or writes events exits 2. HUSHRUN_REPOSITORY_TOKENgoes only to a URL thatHUSHRUN_REPOSITORY_URLor--repository-urlnames, never to one onlyhushrun.yamlnames, so a change tohushrun.yamlcan'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 --startdecides, recordsstartedfor 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 asjson), and writes a step summary with the mode and the reason. It exits 3 when the plan isblocked, which fails the workflow on purpose. With several scopes,actionable_scopeslists the ones that deploy, in place of the per-scope outputs. - test:
hushrun run --stage stagingruns exactly the plan's checks, and records each result. It exits 8 when a check fails. - deploy: runs when the mode is
promote,refreshorfull:hushrun guard --stage stagingstops a deploy of a revision that is no longer the branch's head (Guard).hushrun artifact resolve --stage stagingoutputsref, the image to deploy, andbuild. Inpromoteandrefresh, it copies the upstream's image by digest when needed.- When
buildistrue: your build pushes the image to$REF, thenhushrun attest --stage staging --artifact image=$REFrecords it, pinned by digest. - Your deploy deploys
$REF, thenhushrun mark deployed --stage staging --artifact image=$REF. hushrun run <checks> --stage stagingruns the deployment checks other stages require of this one, the plan'sdeployment_checks.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:
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 runsThe 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:
guardoutputsstale=falseand exits 0. - The branch moved on:
guardrecordssuperseded, outputsstale=true, and exits 0. The deploy steps after it skip onsteps.guard.outputs.stale == 'true', and the newer run deploys instead. --on-stale failexits 4 instead of 0.--branchand--remotename 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
prstage with nobranchthat 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:
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: productionIts inputs:
| Input | What it is |
|---|---|
stage | The stage to deploy, as hushrun. names it. Required |
version | The exact hushrun release to install, such as 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- |
image | The artifact of hushrun. that build and deploy work on. Default image |
scope | The scope to deploy, in a repository with several. Default its only one |
force- | 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) |
toolchain, toolchain- | The toolchain the checks need, go, node, python, java or none, and its version |
runs- | The runner of every job. Default ubuntu- |
signing | sigstore to sign keylessly, as the repository's CI |
deploy- | The GitHub environment of the deploy job |
signing- | The GitHub environment of the jobs that sign |
repository-, repository- | The repository's URL from your settings. Empty, hushrun reads hushrun.'s |
gcp-, gcp- | Google Cloud credentials through workload identity federation |
aws-, aws- | AWS credentials through GitHub's OIDC provider |
azure-, azure-, azure- | 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:
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: sigstoreCheck the setup
hushrun doctordoctor 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'stransparency_log; - who counts, and, with
HUSHRUN_SIGNINGor 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.yamlnames; - that every scope covers the workflows that run hushrun;
- configuration baked into the build, such as
NEXT_PUBLIC_*orVITE_*; - with
GITHUB_TOKEN(orGH_TOKEN) andGITHUB_REPOSITORYset, 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, throughworkflow_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 withallow_force: falserefuses 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.
guardrecordssuperseded, 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-runto 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
runandattestsay 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:
| Code | Meaning |
|---|---|
| 2 | A usage or configuration error, such as a repository_ that doesn't match, or a pull request without - |
| 3 | plan: blocked |
| 4 | guard -: 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 -: a check failed, and its result was recorded |
| 9 | The signer is unavailable: GitHub's ID token, Fulcio, or the timestamp authority |