📖 Guide
GitHub Actions — Complete Reference
Complete GitHub Actions cheat sheet — workflows, triggers, jobs, and CI/CD patterns.
152 commands across 18 categories
Related tool:GitHub Actions Validator →
Workflow BasicsTriggersJobs & StepsEnvironment VariablesSecretsMatrix StrategyCachingArtifactsCommon ActionsExpressions & ContextsReusable WorkflowsComposite ActionsEnvironments & DeploymentsRunners & ContainersJob Outputs & Data FlowContexts ReferenceWorkflow CommandsTroubleshooting & Gotchas
Workflow Basics
| Command | Description |
|---|---|
.github/workflows/ci.yml | Workflow files live in .github/workflows/ directory |
name: CI Pipeline | Set the workflow display name |
on: push | Trigger workflow on push events |
runs-on: ubuntu-latest | Specify the runner OS (ubuntu-latest, windows-latest, macos-latest) |
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 | Minimal workflow structure |
concurrency:
group: ci-main
cancel-in-progress: true | Cancel redundant runs for the same group |
permissions:
contents: read
packages: write | Set GITHUB_TOKEN permissions for the workflow |
Triggers
| Command | Description |
|---|---|
on:
push:
branches: [main, develop] | Trigger on push to specific branches |
on:
pull_request:
types: [opened, synchronize] | Trigger on PR events |
on:
push:
paths:
- "src/**"
- "!docs/**" | Trigger only when specific paths change (with exclusion) |
on:
schedule:
- cron: "0 6 * * 1" | Scheduled trigger (every Monday at 06:00 UTC)Try the Cron Expression Builder → |
on:
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production] | Manual trigger with input parameters |
on:
release:
types: [published] | Trigger on new release publication |
on:
workflow_call:
inputs:
node-version:
type: string
required: true | Make workflow reusable (callable from other workflows) |
on:
push:
tags:
- "v*" | Trigger on version tags |
Jobs & Steps
| Command | Description |
|---|---|
steps:
- uses: actions/checkout@v4 | Check out the repository code |
- name: Install deps
run: npm ci | Run a shell command |
- name: Multi-line script
run: |
echo "Line 1"
echo "Line 2" | Multi-line shell commands |
needs: [build, lint] | Job dependency — wait for other jobs to finish |
if: github.ref == 'refs/heads/main' | Conditional job/step execution |
- name: Upload
if: success()
run: echo "Only on success" | Run step only if previous steps succeeded (also: failure(), always(), cancelled()) |
timeout-minutes: 15 | Set timeout for a job |
continue-on-error: true | Allow a step to fail without failing the job |
working-directory: ./frontend | Set working directory for a step's run commands |
services:
postgres:
image: postgres:15
ports:
- 5432:5432 | Run service containers alongside the job |
Environment Variables
| Command | Description |
|---|---|
env:
NODE_ENV: production | Set env vars at workflow, job, or step level |
GITHUB_SHA | Built-in: the commit SHA that triggered the workflow |
GITHUB_REF | Built-in: the branch or tag ref (e.g. refs/heads/main) |
GITHUB_REPOSITORY | Built-in: owner/repo name |
GITHUB_ACTOR | Built-in: username that triggered the workflow |
GITHUB_RUN_NUMBER | Built-in: sequential number for each workflow run |
echo "MY_VAR=value" >> "$GITHUB_ENV" | Set environment variable for subsequent steps |
echo "result=success" >> "$GITHUB_OUTPUT" | Set step output for use in other steps |
Secrets
| Command | Description |
|---|---|
secrets.GITHUB_TOKEN | Auto-generated token with repo permissions |
secrets.MY_SECRET | Access a repository or organization secret |
env:
API_KEY: ${{ secrets.API_KEY }}e.g. Use single quotes around the expression in YAML | Pass secret as environment variable |
vars.MY_VARIABLE | Access a repository configuration variable (non-secret) |
environment: production | Use environment-specific secrets by specifying an environment |
secrets.inherite.g. jobs:
call:
uses: ./.github/workflows/reusable.yml
secrets: inherit | Pass all secrets to a called reusable workflow |
Matrix Strategy
| Command | Description |
|---|---|
strategy:
matrix:
node-version: [18, 20, 22] | Run job across multiple configurations |
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }} | Use matrix value in a step |
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]e.g. runs-on: ${{ matrix.os }} | Multi-dimensional matrix (creates all combinations) |
include:
- os: ubuntu-latest
node: 22
experimental: true | Add specific combinations to the matrix |
exclude:
- os: windows-latest
node: 18 | Exclude specific combinations from the matrix |
strategy:
fail-fast: false | Continue other matrix jobs even if one fails |
strategy:
max-parallel: 2 | Limit concurrent matrix jobs |
Caching
| Command | Description |
|---|---|
- uses: actions/cache@v4
with:
path: ~/.npm
key: npm-${{ hashFiles('**/package-lock.json') }} | Cache npm dependencies |
restore-keys: |
npm-${{ runner.os }}- | Fallback cache keys when exact match not found |
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm | Built-in caching with setup-node (simplest) |
- uses: actions/cache@v4
with:
path: |
~/.cache/pip
~/.local
key: pip-${{ hashFiles('**/requirements.txt') }} | Cache multiple directories |
- uses: actions/cache/restore@v4
with:
path: ./dist
key: build-${{ github.sha }} | Restore cache without saving (read-only) |
- uses: actions/cache/save@v4
if: always()
with:
path: ./dist
key: build-${{ github.sha }} | Save cache even if job fails |
Artifacts
| Command | Description |
|---|---|
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/ | Upload build artifacts |
- uses: actions/download-artifact@v4
with:
name: build-output | Download artifacts in a later job |
retention-days: 5 | Set artifact retention period |
if-no-files-found: error | Fail if no files match the path (warn, ignore, error) |
path: |
dist/
coverage/
!dist/**/*.map | Upload multiple paths with exclusions |
- uses: actions/download-artifact@v4
with:
name: build-output
path: ./downloaded | Download artifact to a specific directory |
Common Actions
| Command | Description |
|---|---|
uses: actions/checkout@v4 | Check out repository (most used action) |
uses: actions/setup-node@v4
with:
node-version: 20 | Set up Node.js environment |
uses: actions/setup-python@v5
with:
python-version: "3.12" | Set up Python environment |
uses: docker/build-push-action@v5
with:
push: true
tags: user/app:latest | Build and push Docker image |
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} | Log in to container registry |
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist | Deploy to GitHub Pages |
uses: softprops/action-gh-release@v1
with:
files: dist/*.zip | Create GitHub Release with assets |
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({ ... }) | Run JavaScript with GitHub API access |
Expressions & Contexts
| Command | Description |
|---|---|
github.event_name | The event that triggered the workflow (push, pull_request, etc.) |
github.event.pull_request.number | PR number from the event payload |
runner.os | The OS of the runner (Linux, Windows, macOS) |
steps.step_id.outputs.result | Access output from a previous step by its id |
needs.job_id.outputs.my_output | Access output from a dependent job |
contains(github.event.head_commit.message, '[skip ci]') | Check if commit message contains a string |
startsWith(github.ref, 'refs/tags/v') | Check if ref starts with a value |
toJSON(github.event) | Convert context to JSON string (useful for debugging) |
hashFiles('**/package-lock.json') | Generate hash of files (used for cache keys) |
format('Hello {0}, from {1}!', 'world', 'GHA') | String formatting function |
endsWith(github.ref, '/release') | Check if a string ends with a given value — e.g. detect a branch named like *-release |
join(fromJSON('["a","b","c"]'), ', ') | Join array elements into a single string (default separator is a comma) |
fromJSON(needs.setup.outputs.matrix) | Parse a JSON string into an object/array — commonly used to build a matrix from a prior job's output |
if: success() | The implicit default when a step or job has no if: at all — write always(), cancelled(), or failure() explicitly to change it |
Reusable Workflows
| Command | Description |
|---|---|
on:
workflow_call:
inputs:
node-version:
type: string
required: true
environment:
type: string
default: staging | Define a reusable workflow's callable inputs (the workflow_call trigger) |
on:
workflow_call:
secrets:
npm-token:
required: true | Declare a secret a reusable workflow requires its caller to pass |
on:
workflow_call:
outputs:
build-version:
description: "The built version string"
value: ${{ jobs.build.outputs.version }} | Expose a workflow-level output computed by one of the reusable workflow's jobs |
jobs:
call-ci:
uses: ./.github/workflows/ci.yml | Call a reusable workflow from the same repository |
jobs:
call-ci:
uses: octo-org/shared-workflows/.github/workflows/ci.yml@main | Call a reusable workflow from another repository at a specific ref |
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
with:
node-version: "20" | Pass inputs to a called reusable workflow |
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
secrets:
npm-token: ${{ secrets.NPM_TOKEN }} | Pass one specific secret to a called reusable workflow |
jobs:
call-ci:
uses: ./.github/workflows/ci.yml
secrets: inherit | Pass every secret available to the caller down to the called workflow |
${{ inputs.node-version }} | Read a workflow_call input from inside the called workflow |
${{ needs.call-ci.outputs.build-version }} | Read a called reusable workflow's output from the caller job |
Composite Actions
| Command | Description |
|---|---|
name: "My Action"
description: "What it does"
runs:
using: "composite"
steps:
- run: echo "hi"
shell: bash | Minimal action.yml for a composite action — every run: step needs its own shell: |
inputs:
who-to-greet:
description: "Name to greet"
required: true
default: World | Declare a composite action's inputs in action.yml |
${{ inputs.who-to-greet }} | Read a composite action's input from inside its own steps |
outputs:
greeting:
description: "The generated greeting"
value: ${{ steps.greet.outputs.text }} | Declare a composite action output, sourced from one of its own steps |
runs:
using: "composite"
steps:
- id: greet
run: echo "text=Hello" >> "$GITHUB_OUTPUT"
shell: bash | Set the step output that a composite action's outputs: block references |
- uses: ./.github/actions/my-composite-action
with:
who-to-greet: Mona | Call a local composite action and pass its inputs |
runs:
using: "composite"
steps:
- run: npm install
shell: bash | shell: is mandatory on every run: step inside a composite action, unlike a normal job step |
outputs:
greeting:
value: ${{ steps.greet.outputs.text }} | A composite action's outputs are declared once in action.yml, not per-caller |
Environments & Deployments
| Command | Description |
|---|---|
environment: production | Run a job against an environment by name (string form) |
environment:
name: production
url: https://example.com | Run a job against an environment with a linked deployment URL |
environment:
name: production
url: ${{ steps.deploy.outputs.url }} | Set an environment's URL dynamically from a step output |
environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }} | Choose an environment at runtime with an expression |
jobs:
deploy:
environment: production
steps:
- run: echo ${{ secrets.PROD_API_KEY }} | Environment-scoped secrets are exposed only to jobs that reference that environment |
# Settings → Environments → Required reviewers | Required reviewers — up to 6 people/teams; only one approval is needed for the job to proceed |
# Settings → Environments → Wait timer | Wait timer — delay a job for a set number of minutes before it's allowed to access the environment |
# Settings → Environments → Deployment branches and tags | Deployment branch policy — restrict which branches/tags are allowed to deploy to an environment |
Runners & Containers
| Command | Description |
|---|---|
runs-on: [self-hosted, linux, x64] | Target a self-hosted runner by labels — all listed labels must match |
runs-on:
group: production-runners | Target a runner group instead of (or alongside) labels |
runs-on:
group: production-runners
labels: ubuntu-24.04-16core | Combine a runner group with a specific label |
self-hosted | Default label every self-hosted runner gets automatically, alongside OS (linux/windows/macos) and architecture (x64/arm/arm64) labels |
container: node:20 | Run every step of a job inside a container image (shorthand form) |
container:
image: node:20
env:
NODE_ENV: test
ports:
- 80
volumes:
- my_docker_volume:/volume_mount
options: --cpus 1 | Run a job in a container with extra Docker options |
container:
image: ghcr.io/org/private-image:latest
credentials:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} | Authenticate to a private registry for a container job |
container:
image: node:20
steps:
- run: |
if [[ "$FOO" == "bar" ]]; then echo match; fi
shell: bash | Gotcha: a container job's run: steps default to sh, not bash, even when the image has bash installed — bash-only syntax like [[ ]] fails under sh unless shell: bash is set explicitly |
runs-on: [self-hosted, gpu]
container:
image: nvidia/cuda:12.4.0-base-ubuntu22.04 | Combine a self-hosted runner with a container image for a reproducible toolchain |
options: --cpus 1 --memory 2g | container: options: accepts extra `docker run` flags — --network and --entrypoint are not supported here |
Job Outputs & Data Flow
| Command | Description |
|---|---|
jobs:
build:
outputs:
version: ${{ steps.get-version.outputs.value }}
steps:
- id: get-version
run: echo "value=1.2.3" >> "$GITHUB_OUTPUT" | Declare a job-level output sourced from one of its steps |
jobs:
deploy:
needs: build
steps:
- run: echo ${{ needs.build.outputs.version }} | Consume another job's output via needs.<job_id>.outputs.<name> |
needs: [build, test] | Depend on multiple jobs — outputs from all of them become available via needs |
needs.build.result | Read a dependency job's result: success, failure, cancelled, or skipped |
echo "value=1.2.3" >> "$GITHUB_OUTPUT" | Write the step output that a job-level outputs: block (and other jobs, via needs) can read |
jobs:
build:
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
outputs:
last-os: ${{ matrix.os }} | Gotcha: a matrix job's outputs only reflect its LAST completed matrix leg, not all of them |
Contexts Reference
| Command | Description |
|---|---|
github | Info about the workflow run and triggering event — github.event, github.sha, github.ref, github.actor, etc. |
env | Variables set at workflow, job, or step level with env: — not available in id or uses keys |
vars | Configuration variables set at the repository, organization, or environment level (non-secret) |
job | Info about the currently running job, e.g. job.status |
jobs | Reusable-workflow only — used to populate jobs.<job_id>.outputs for the on.workflow_call.outputs mapping |
steps | Outputs and conclusions of steps already run in the current job — unavailable before those steps execute |
runner | Info about the runner executing the job — runner.os, runner.arch, runner.temp, runner.tool_cache |
secrets | Names and values of secrets available to the run — unavailable inside a composite action for security |
strategy | Info about the current job's matrix execution strategy |
matrix | The matrix properties assigned to the current job, e.g. matrix.os, matrix.node-version |
needs | Outputs and results of jobs the current job depends on via needs: |
inputs | Inputs passed to a reusable workflow (workflow_call) or a manually triggered run (workflow_dispatch) |
Workflow Commands
| Command | Description |
|---|---|
echo "### Build Summary" >> "$GITHUB_STEP_SUMMARY" | Write Markdown to the job summary shown on the workflow run page |
echo "- ✅ Build passed" >> "$GITHUB_STEP_SUMMARY" | Append additional Markdown lines to the step summary |
echo "::group::Install dependencies"
npm ci
echo "::endgroup::" | Group log lines into a collapsible section in the run log |
echo "::add-mask::${{ secrets.MY_TOKEN }}" | Mask a value in the log output, even one that isn't a registered secret |
echo "::notice file=app.js,line=1,col=5::Missing semicolon" | Create a notice annotation on a specific file/line, shown in the checks UI |
echo "::warning file=app.js,line=1,title=Deprecated::This API is deprecated" | Create a warning annotation with a title |
echo "::error file=app.js,line=1,col=5,endColumn=7::Missing semicolon" | Create an error annotation pointing at a specific column range |
echo "::debug::Setting up variables" | Print a debug message — only visible when the ACTIONS_STEP_DEBUG secret is set to true |
Troubleshooting & Gotchas
| Command | Description |
|---|---|
on:
pull_request: | Gotcha: for a pull_request from a FORK, GITHUB_TOKEN is read-only and no other secrets are passed to the run |
GITHUB_TOKEN | New repositories default this to read-only permissions; older repos may still default to read/write — set permissions: explicitly instead of relying on the default |
permissions: read-all | Shorthand to grant GITHUB_TOKEN read access to every scope |
permissions: write-all | Shorthand to grant GITHUB_TOKEN write access to every scope — avoid unless genuinely needed |
permissions: {} | Remove every GITHUB_TOKEN permission — any scope not listed under permissions: is set to none |
- uses: actions/checkout@v4
with:
fetch-depth: 0 | Gotcha: the default fetch-depth: 1 is a shallow clone — full history and tags need fetch-depth: 0 |
- uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true | Force tag refs to be fetched even when combined with a limited fetch-depth |
if: always() && (needs.build.result == 'success' || needs.build.result == 'skipped') | Gotcha: by default a job is skipped if ANY of its needs: dependencies failed or was itself skipped — always() overrides that, so re-check needs.<job>.result explicitly to still exclude real failures |
if: ${{ !cancelled() }} | Run a step/job regardless of prior failure, but not if the run itself was cancelled |
on:
push:
branches: [main]
branches-ignore: [main] | Gotcha: branches and branches-ignore (or paths and paths-ignore) cannot both be set for the same event — use one filter with ! to exclude instead |
📖 Free, searchable command reference. Bookmark this page for quick access.