Skip to content

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 init adds 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/server

Edit 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 adopt

It 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 doctor

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

ModeThe step summary saysWhat you do
promoteDeploying the image an earlier run built and checked, by digest. Nothing is rebuilt or checked again.Nothing
refreshDeploying the image an earlier run built, after running the checks below first.Nothing. Only checks without valid evidence run (Which checks run)
fullBuilding, running the checks below, then deploying.Nothing
currentNothing to deploy. This repository already runs this code.Nothing. To deploy it again, re-run the workflow, or run it by hand
blockedNot 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 where allow_force: false forbids it.
  • Force a full run. In the Actions tab, run the workflow by hand with force_full checked, which the workflow passes to the reusable workflow's force-full input. A forced run builds and runs every required check.
  • A newer commit landed while deploying. guard records superseded, 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-run to 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 run and attest say 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_target started: 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_url that 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 refresh fetches.

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's repository_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 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, 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 --start decides, and records started for the scope it deploys. It writes its outputs to $GITHUB_OUTPUT (mode, reason, digest, revision, attempt, build, ref, artifacts, checks, and the whole plan as json), and the step summary. It exits 3 when the plan is blocked. With several scopes, actionable_scopes lists the ones that deploy, in place of the per-scope outputs.
  • test: hushrun run 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 stops a deploy of a revision that is no longer the branch's head (Guard).
    2. hushrun artifact resolve outputs ref, the image to deploy, and build.
    3. When build is true, your build pushes the image to $REF, and hushrun attest --artifact image=$REF records it, pinned by digest.
    4. Your deploy deploys $REF, then hushrun mark deployed --artifact image=$REF.
    5. 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 one with: { version: 0.9.0, sha256: … } pins. Pass the plan job's version output 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: main or 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, guard outputs stale=false and exits 0.
  • If 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. --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 main hasn'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:

.github/workflows/hushrun.yml
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 job

Its inputs:

InputWhat it is
versionThe hushrun release every job installs, such as 0.9.0. Default: the newest release when the plan job starts
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, whose protection rules guard the deploy
signing-environmentThe GitHub environment of the jobs that sign
repository-url, repository-baseThe repository's URL from your settings. When 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.

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:

.github/workflows/hushrun.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:
      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: sigstore

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, a pull request without --config-ref, or a hushrun.yaml that plan refuses
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 --no-fallback: the image can't be reused
8run: a check failed, and its result was recorded
9The signer is unavailable: GitHub's ID token, Fulcio, or the timestamp authority

Next

Markdown