Deploys on GitHub Actions
Updated
This guide sets up a repository that deploys from main through hushrun's reusable workflow. A push of files that already passed deploys the image built for them, by digest, with no build and no check to repeat. For what a deploy pipeline is, start at Deploys.
Before you begin
- Admin rights on the repository.
- An org on hushrun (sign up), with the repository added: an org admin's
npx hushrun initadds it. - The container registry your image already uses.
- hushrun installed: see Quickstart.
Write hushrun.yaml
hushrun init --pipeline --image us-docker.pkg.dev/acme/api/serverEdit the file: the checks the deploy requires and their commands, the branch, and in a monorepo the scopes and digest.exclude (What a deploy requires). Then check it:
$ hushrun policy validate hushrun.yaml is valid: 1 scope(s), 3 check(s), 1 artifact(s), and it deploys
In a monorepo, include .github/workflows/hushrun.yml and hushrun.yaml in each scope, so that a change to the deploy changes the digest.
Connect the repository
Run npx hushrun init. It adds repository_url and transparency_log to hushrun.yaml. Commit the file: hushrun reads it as committed.
With the GitHub CLI (gh) signed in, init also tells hushrun the repository's GitHub id, which is how hushrun's server recognizes the repository's CI. Without gh, the server learns it from the repository's first CI run.
Write the workflow
hushrun adoptIt writes .github/workflows/hushrun.yml, one short workflow that calls hushrun's reusable workflow. The name leaves a deploy.yml of your own alone: remove that one once this one deploys. Set build, deploy and your cloud credentials, and pin the reusable workflow to a release commit: see The reusable workflow.
adopt keeps a file that differs from what it would write. --dry-run prints what it would write, and --force replaces. --branch names the branch it deploys from, and --image the image's repository.
Check the setup
hushrun doctorIt exits 2 when a check fails, and says what to fix. Commands lists everything it checks.
Push to main
Push to main. The first run plans full: it runs the checks, builds, deploys, and records each step. Re-run it, or later revert to these files, and it plans promote: it deploys the image it already built and checked, by digest, with no build and no check to repeat.
Each plan job's step summary says what the run does: see Day to day.
Day to day
Each plan job's step summary starts with what the run does, then gives the mode and the reason. For scopes other than default, it's a table with a row for each scope instead.
| Mode | The step summary says | What you do |
|---|---|---|
promote | Deploying the image an earlier run built and checked, by digest. Nothing is rebuilt or checked again. | Nothing |
refresh | Deploying the image an earlier run built, after running the checks below first. | Nothing. Only checks without valid evidence run (Which checks run) |
full | Building, running the checks below, then deploying. | Nothing |
current | Nothing to deploy. This repository already runs this code. | Nothing. To deploy it again, re-run the workflow, or run it by hand |
blocked | Not deployed. The job fails on purpose; the reason says what to do. | What the reason says. See below |
- A blocked plan. The reason says what to do: the run is on another branch than
deploy.branch, or it was forced whereallow_force: falseforbids it. - Force a full run. In the Actions tab, run the workflow by hand with
force_fullchecked, which the workflow passes to the reusable workflow'sforce-fullinput. A forced run builds and runs every required check. - A newer commit landed while deploying.
guardrecordssuperseded, and the deploy steps skip. The newer run deploys instead. - Re-run failed jobs. A re-run is a new attempt of the same run, and hushrun records it as one.
- Clear old events. An org admin runs
hushrun repository gc --older-than 180d --dry-runto see what would go, then runs it without--dry-run. It keeps the newest deployment of each scope. - 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.
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. It needs no secret and no key.
- 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, which hushrun's server knows as the repository's CI. The events count as
ci. - Any workflow of the repository signs as its CI, except a run that
pull_request_targetstarted: it 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. - 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's offline, against the Sigstore trust root built into hushrun, or a newer one
hushrun sigstore refreshfetches.
HUSHRUN_SIGNING=sigstore is the default in a job with an ID token and no key. The reusable workflow sets it with signing: sigstore.
Public-good Sigstore logs every certificate it issues in a public log, with the repository's and the workflow's names. To keep a private repository's names 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), whose events don't count while the repository takes CI's evidence only.
The repository's URL in CI
- hushrun reads the URL from the committed
hushrun.yaml'srepository_url, 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 workflow passes it, empty when unset. When it's 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, 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 --startdecides, and recordsstartedfor the scope it deploys. It writes its outputs to$GITHUB_OUTPUT(mode,reason,digest,revision,attempt,build,ref,artifacts,checks, and the whole plan asjson), and the step summary. It exits 3 when the plan isblocked. With several scopes,actionable_scopeslists the ones that deploy, in place of the per-scope outputs. - test:
hushrun runruns 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 guardstops a deploy of a revision that is no longer the branch's head (Guard).hushrun artifact resolveoutputsref, the image to deploy, andbuild.- When
buildistrue, your build pushes the image to$REF, andhushrun attest --artifact image=$REFrecords it, pinned by digest. - Your deploy deploys
$REF, thenhushrun mark deployed --artifact image=$REF. hushrun mark passed --artifact image=$REF.
- report: when the run is cancelled,
hushrun mark superseded --reason cancelled. When a job failed or timed out,hushrun mark failed --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.
To write these jobs by hand:
- Give every job
permissions: { contents: read, id-token: write }. hushrun asks GitHub for nothing but the ID token. - Install hushrun with
hushrun/hushrun/action/setup, pinned to a release commit. It installs the newest release, checked against the SHA-256 published with it, or the onewith: { version: 0.9.0, sha256: … }pins. Pass the plan job'sversionoutput to the later jobs, so a run never mixes two releases. - Check out the revision the plan was made for in every job: never check out
ref: mainor pass--rev.
Guard
A newer commit can land on the branch while a deploy runs. hushrun guard checks that the revision being deployed is still the head of deploy.branch on origin:
- If it is,
guardoutputsstale=falseand exits 0. - If 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 fail exits 4 instead. --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 run --all --config-ref "origin/$GITHUB_BASE_REF"Without --config-ref, or with another branch, every command that reads or writes events exits 2.
- A pull request's jobs sign as the repository's CI too. Run its checks there, and deploy only from
main. - The checks a pull request passes count for the same digest. When
mainhasn't moved, the merged commit has the same files, and the deploy doesn't run those checks again.
The reusable workflow
hushrun adopt writes one short workflow that calls hushrun's reusable workflow, which holds the plan, test, deploy and report jobs above. With its values set:
name: Deploy
on:
push:
branches: [main]
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' || format('deploy-refused-{0}', github.run_id) }}
cancel-in-progress: false
permissions:
contents: read
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:
# version: 0.9.0 # pins hushrun; without it, each run installs the newest release
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: [email protected]
signing: sigstore # keylessly, as this repository's CI
# deploy-environment: NAME # a GitHub environment, whose rules guard the deploy jobIts inputs:
| Input | What it is |
|---|---|
version | The hushrun release every job installs, such as 0.. Default: the newest release when the plan job starts |
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, whose protection rules guard the deploy |
signing- | The GitHub environment of the jobs that sign |
repository-, repository- | The repository's URL from your settings. When 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.
Which hushrun CI runs
Each run installs the newest hushrun release when its plan job starts, and every later job of the run installs the same one. A new release reaches your pipeline at its next run: its fixes without an edit, and a bad release as fast.
To pin one release, set version, such as version: 0.9.0, or run hushrun adopt --hushrun-version 0.9.0. A pinned version changes only through a commit you review.
A monorepo
Call the reusable workflow once per scope. Each plans its own digest, so a scope whose files didn't change plans current 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:
scope: ${{ matrix.scope }}
image: ${{ matrix.scope }}-image
build: docker build -t "$REF" "services/$SCOPE" && docker push "$REF"
deploy: gcloud run deploy "$SCOPE" --image "$REF" --region us-central1
signing: sigstoreExit 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, a pull request without -, or a hushrun. that plan refuses |
| 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 -: the image can't be reused |
| 8 | run: a check failed, and its result was recorded |
| 9 | The signer is unavailable: GitHub's ID token, Fulcio, or the timestamp authority |
Next
- The planner: what each mode means, and what happens in common situations.
- Evidence from agents and laptops: let agents run the deploy's checks before a push.
- Transparency log: monitor hushrun's server from your CI.