GitHub Actions: workflows for CI/CD and automation
.github/workflows/*.yml
GitHub Actions runs automated workflows in response to events in your repository: a push, a pull request, a schedule, a
button click. Use it to test and lint code, build and publish packages and container images, deploy, and automate chores
such as labeling issues. Workflows are YAML files in .github/workflows/ on your repository. Runners are the machines that
run them (GitHub hosts them, or you can host your own). This sheet is a syntax and pattern reference. The GitHub sheet has the
wider picture. Actions features and pricing change, so check docs.github.com/actions for the current details.
The vocabulary
Workflow A YAML file that describes an automated process
Event What starts a workflow (push, pull_request, schedule, workflow_dispatch ...)
Job A group of steps that runs on one runner. Jobs run in PARALLEL unless you use needs:
Step One task in a job: either "uses:" an action or "run:" a shell command. Steps run in order
Action A reusable unit of work (for example actions/checkout@v4)
Runner The machine that runs a job (a fresh virtual machine for each job on GitHub-hosted runners)
Artifact Files a job saves so people or other jobs can download them
Secret An encrypted value (a token, a password) available to workflows
Environment A named deployment target (staging, production) with its own secrets and approval rules
A minimal workflow
name: CI # shown in the Actions tab
on: [push, pull_request] # events that trigger it
jobs:
test: # a job id you choose
runs-on: ubuntu-latest # the runner
steps:
- uses: actions/checkout@v4 # get the code (nothing is checked out by default)
- run: echo "Hello from ${{ github.repository }}"
- run: | # multi-line shell script
npm ci
npm test
Commit the file, push, and watch it under the repository's Actions tab.
Triggers (on:)
on:
push:
branches: [main, "release/**"] # only these branches (glob patterns)
branches-ignore: ["wip/**"] # ...or exclude some (do not mix with branches for the same event)
tags: ["v*"] # tag pushes such as v1.2.0
paths: ["src/**", "package.json"] # only when these files change
paths-ignore: ["**.md", "docs/**"]
pull_request:
types: [opened, synchronize, reopened, ready_for_review] # default types shown here
branches: [main] # PRs that target main
workflow_dispatch: # a manual "Run workflow" button
inputs:
environment:
description: Where to deploy
type: choice
options: [staging, production]
default: staging
dry_run:
type: boolean
default: true
schedule:
- cron: "30 3 * * 1-5" # minute hour day month weekday, in UTC. Always quote it
release:
types: [published]
workflow_call: # makes this workflow reusable by others (see Reusable workflows)
workflow_run: # run after another workflow finishes
workflows: ["CI"]
types: [completed]
repository_dispatch: # started by an API call from outside
types: [deploy-request]
Other events: issues, issue_comment, pull_request_review, discussion, create, delete, fork, watch, page_build, merge_group.
Scheduled workflows can be delayed at busy times, only run on the default branch, and pause after 60 days without repository activity.
Paths filters do not apply to tag pushes. A workflow must exist on the default branch for some triggers (schedule, workflow_dispatch UI) to work.
Jobs
jobs:
build:
name: Build and test
runs-on: ubuntu-latest # ubuntu-latest, ubuntu-24.04, windows-latest, macos-latest, or a self-hosted label
timeout-minutes: 15 # stop runaway jobs (default is 360)
needs: [lint] # wait for these jobs (and skip if they fail)
if: github.ref == 'refs/heads/main' # conditions (no ${{ }} needed in if:)
continue-on-error: false # true = a failure here does not fail the workflow
permissions:
contents: read
env:
NODE_ENV: test
defaults:
run:
shell: bash
working-directory: ./app
outputs:
version: ${{ steps.ver.outputs.version }}
steps: [...]
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying version ${{ needs.build.outputs.version }}"
Steps
- name: Friendly name # shown in the log
id: ver # so later steps can read its outputs
uses: actions/setup-node@v4 # run an action ...
with: # ...with these inputs
node-version: 22
- name: Run a script
run: ./scripts/test.sh # ... or run a shell command
shell: bash # bash, pwsh, python, sh, cmd
working-directory: ./app
env:
API_URL: https://example.com
if: success() # conditions: success(), failure(), always(), cancelled()
continue-on-error: true
timeout-minutes: 5
Matrix builds (many combinations)
strategy:
fail-fast: false # do not cancel the other combinations when one fails
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest]
node: [20, 22]
include:
- os: ubuntu-latest
node: 24 # add an extra combination
experimental: true
exclude:
- os: windows-latest
node: 20 # skip this combination
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
Every extra matrix value multiplies the number of jobs and the minutes you use.
Contexts and expressions: ${{ ... }}
github.sha github.ref github.ref_name github.event_name github.actor github.repository github.run_id github.workspace
github.event.pull_request.number github.event.inputs.NAME (or inputs.NAME) github.head_ref (PR source branch)
env.NAME secrets.NAME vars.NAME runner.os runner.temp runner.arch
matrix.KEY needs.JOB.outputs.KEY steps.ID.outputs.KEY steps.ID.outcome / conclusion job.status
Operators: == != < > && || ! Strings use single quotes: github.ref == 'refs/heads/main'
Functions:
contains('Hello world', 'world') startsWith(github.ref, 'refs/tags/') endsWith(github.ref, '-rc')
format('v{0}.{1}', 1, 2) join(matrix.os, ', ') toJSON(github) fromJSON(steps.x.outputs.json)
hashFiles('**/package-lock.json') # a hash of files, perfect for cache keys
success() failure() always() cancelled() # status checks for if:
Examples:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: startsWith(github.ref, 'refs/tags/v')
if: ${{ failure() }} # run only when an earlier step failed
if: always() # run even after failures or cancellation (cleanup, notifications)
if: github.actor != 'dependabot[bot]'
if: contains(github.event.pull_request.labels.*.name, 'deploy')
Environment variables and files (passing data between steps)
Built-in variables: CI=true, GITHUB_SHA, GITHUB_REF, GITHUB_REF_NAME, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_ACTOR, GITHUB_WORKSPACE, RUNNER_OS, RUNNER_TEMP
Set an output (read it later with steps.ID.outputs.NAME):
- id: ver
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
- run: echo "Building ${{ steps.ver.outputs.version }}"
Set an environment variable for later steps: echo "MY_VAR=value" >> "$GITHUB_ENV"
Add a folder to PATH for later steps: echo "$HOME/.local/bin" >> "$GITHUB_PATH"
Write a Markdown summary on the run page: echo "### Tests passed :rocket:" >> "$GITHUB_STEP_SUMMARY"
Multi-line value:
{
echo "notes<<EOF"
cat notes.txt
echo "EOF"
} >> "$GITHUB_OUTPUT"
The old ::set-output and ::set-env commands are deprecated and removed. Use the files above.
Log commands (print to stdout):
echo "::notice::Deployed to staging" echo "::warning file=app.js,line=10::Deprecated call"
echo "::error file=app.js,line=12::Something failed" echo "::group::Install" ... echo "::endgroup::"
echo "::add-mask::$MY_VALUE" # hide a value that is not a registered secret in logs
echo "::debug::visible only with debug logging"
Secrets, variables and tokens
Settings > Secrets and variables > Actions: Secrets (encrypted, masked in logs) | Variables (plain text configuration)
Scopes: repository, environment (with approval rules), and organization (share with chosen repos)
env:
API_KEY: ${{ secrets.API_KEY }} # pass a secret to a step as an environment variable (safer than putting it on a command line)
REGION: ${{ vars.AWS_REGION }}
gh secret set API_KEY gh secret set API_KEY --env production gh variable set REGION --body "eu-west-1"
Secrets are NOT passed to workflows triggered by pull requests from forks.
Reusable workflows can receive them with "secrets: inherit" or by name.
The built-in token: ${{ secrets.GITHUB_TOKEN }} (also github.token). It is created for each run and expires when it ends.
Set what it may do:
permissions:
contents: read # actions, checks, contents, deployments, id-token, issues, packages,
pull-requests: write # pages, pull-requests, security-events, statuses: read | write | none
Once you set any permission, all unlisted ones become none. Start with "contents: read" at the top and grant more per job.
Use the token with the gh CLI in a step:
- run: gh pr comment ${{ github.event.pull_request.number }} --body "Tests passed"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Actions taken with GITHUB_TOKEN do not trigger other workflows (this prevents loops). If you need that, use a GitHub App token or a PAT.
Caching and artifacts
Language setup actions can cache for you:
- uses: actions/setup-node@v4
with: { node-version: 22, cache: npm } # also: cache: pip (setup-python), gradle, maven (setup-java), and setup-go caches by default
Manual cache:
- uses: actions/cache@v4
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-
Artifacts (save output, share between jobs):
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
if-no-files-found: error
- uses: actions/download-artifact@v4 # in another job (with "needs")
with:
name: build-output
Do not put secrets in artifacts or caches. Anyone who can read the run can download them.
Service containers and containers
jobs:
test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
ports: ["5432:5432"]
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s --health-timeout 5s --health-retries 10
steps:
- uses: actions/checkout@v4
- run: ./run-integration-tests.sh # connect to localhost:5432
# container: node:22 (optional) run the whole job inside a container image
Common language setups
- uses: actions/setup-python@v5
with: { python-version: "3.12", cache: pip }
- run: pip install -r requirements.txt && pytest
- uses: actions/setup-go@v5
with: { go-version: "stable" }
- run: go build ./... && go test ./...
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: 21, cache: maven }
- run: mvn -B verify
Quote python-version values ("3.10" not 3.10, or YAML turns it into 3.1).
Build and push a container image (GitHub Container Registry)
permissions:
id-token: write # lets the job request a short-lived identity token
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-deploy
aws-region: us-east-1
- run: aws s3 sync ./site s3://my-bucket --delete
Similar actions exist for Azure (azure/login) and Google Cloud (google-github-actions/auth).
Create a cloud role that trusts your repository (and branch or environment), so no long-lived secret is stored in GitHub.
Environments, approvals and concurrency
jobs:
deploy:
environment: production # secrets and required reviewers come from Settings > Environments
concurrency:
group: deploy-production
cancel-in-progress: false # queue deployments instead of cancelling one in progress
Use "concurrency" with cancel-in-progress: true on pull request workflows, so a new push cancels the older, now pointless run:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
Environments can require a person to approve, wait a timer, and limit which branches may deploy.
Reusable workflows and composite actions
Reusable workflow (called by other workflows), .github/workflows/test.yml:
on:
workflow_call:
inputs:
node-version:
type: string
default: "22"
secrets:
npm-token:
required: false
outputs:
result:
value: ${{ jobs.test.outputs.result }}
jobs:
test:
runs-on: ubuntu-latest
outputs:
result: ${{ steps.r.outputs.result }}
steps: [...]
Caller:
jobs:
ci:
uses: ./.github/workflows/test.yml # or: my-org/shared-workflows/.github/workflows/test.yml@v1
with:
node-version: "20"
secrets: inherit
Composite action (a reusable set of steps), .github/actions/setup-app/action.yml:
name: Setup app
description: Install dependencies
inputs:
node-version:
default: "22"
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with: { node-version: "${{ inputs.node-version }}" }
- run: npm ci
shell: bash # composite steps that use run: must set a shell
Use it: - uses: ./.github/actions/setup-app
Other action types: JavaScript actions and Docker container actions (see the docs for "Creating actions").
Monorepo and path-based workflows
on:
pull_request:
paths:
- "services/api/**"
- ".github/workflows/api.yml" # include the workflow file itself
Or detect changes inside a job with a paths filter action, then use job outputs and "if:" to run only what changed.
Required status checks and path filters: a skipped workflow shows as "pending" and can block merges. Use a small always-run "gate" job when needed.
Runners
GitHub-hosted ubuntu-latest (ubuntu-24.04), ubuntu-22.04, windows-latest, macos-latest (Apple silicon), and larger paid runners
A fresh VM every job, cleaned up automatically. Common tools (docker, git, node, python, gh, jq) are preinstalled
Self-hosted Your own machine or VM. Install the runner from Settings > Actions > Runners. Use labels in runs-on: [self-hosted, linux, x64]
Good for special hardware and private networks. You maintain it, and jobs run with access to that machine
Do NOT use self-hosted runners on public repositories. Anyone opening a pull request could run code on your machine.
Free minutes: public repositories are free on standard runners. Private repositories get a monthly allowance by plan, then pay per minute
(Windows costs about twice and macOS about ten times as much as Linux). Check your plan's current limits.
Debugging
- Re-run a job with debug logging: the run page > Re-run jobs > tick "Enable debug logging"
- Or set the secrets/variables ACTIONS_STEP_DEBUG=true and ACTIONS_RUNNER_DEBUG=true
- Print what you have: echo "${{ toJSON(github) }}" (do not do this with secrets or on public logs of sensitive repos)
- Show the environment: env | sort pwd ls -la (careful: env output can include values you set)
- Check syntax before pushing: actionlint (a linter for workflows) actionlint .github/workflows/ci.yml
- Run locally in Docker: act (github.com/nektos/act) act -l act pull_request act -j test
- From the terminal: gh run list gh run view RUN_ID --log-failed gh run watch gh run rerun RUN_ID --failed gh workflow run ci.yml
- A "tmate" step gives you a live SSH session into a runner. Powerful, but it exposes the runner: never use it with secrets, and remove it afterwards
- Read the first error in the log. Later errors often follow from it
Security checklist
[ ] permissions: contents: read at the top of every workflow. Grant more only on jobs that need it
[ ] Pin third-party actions to a full commit SHA (uses: owner/action@0123abcd...). Let Dependabot update them
[ ] Never put untrusted text directly in run:. Issue and PR titles, branch names and comments can contain shell code
Bad: run: echo "${{ github.event.issue.title }}"
Good: env:
TITLE: ${{ github.event.issue.title }}
run: echo "$TITLE"
[ ] Be very careful with pull_request_target and workflow_run: they run in the base repository context and can expose secrets
Never check out and run code from an untrusted fork in those workflows
[ ] Do not print secrets. Never pass them as command-line arguments (they appear in process lists). Use env:
[ ] Use OIDC for cloud logins instead of long-lived keys, and scope the cloud role tightly
[ ] Use environments with required reviewers for production deployments
[ ] Require approval for workflows from first-time contributors (Settings > Actions > General)
[ ] Restrict which actions may run (Settings > Actions > General > Allow select actions)
[ ] Do not use self-hosted runners for public repositories
[ ] Review third-party actions before you use them. An action runs with your workflow's permissions
[ ] Keep secrets out of artifacts and caches, and set short retention-days
[ ] Protect the main branch and .github/workflows/ with CODEOWNERS and required reviews. A workflow change is a code change
Practical patterns
# Only run when something relevant changed
on:
push:
paths-ignore: ["**.md", "docs/**"]
# Cancel an old run when you push again
concurrency: { group: "ci-${{ github.ref }}", cancel-in-progress: true }
# A nightly job with a manual button too
on:
schedule: [{ cron: "0 2 * * *" }]
workflow_dispatch:
# Run a step only on the main branch
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
# Always upload logs, even on failure
- if: always()
uses: actions/upload-artifact@v4
with: { name: logs, path: "*.log" }
# Comment on a pull request from a workflow
- run: gh pr comment "$PR" --body "Preview: https://example.com/pr-$PR"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
# Fail the build on lint errors but keep going to show all results
- run: npm run lint
continue-on-error: ${{ github.event_name == 'pull_request' }}
# Skip CI for a commit by adding this to the commit message: [skip ci] (or [ci skip], [no ci])
# Run a script from the repo
- run: bash scripts/build.sh
# Multi-OS shell differences: set the shell for Windows jobs
- run: echo hello
shell: bash
# Label a pull request from paths (use the official actions/labeler action with .github/labeler.yml)
Common problems
Workflow does not appear or does not run The file must be in .github/workflows/ with a .yml or .yaml extension. Check the "on:" filters (branches, paths) and that YAML is valid
"Invalid workflow file" / YAML error Indentation (spaces only), a missing colon, or an unquoted special value. Run actionlint. Quote strings like "3.10" and cron expressions
Job skipped Its "needs:" job failed or was skipped, or the "if:" was false. Check the condition, and use always() where needed
Secret is empty Wrong name or scope, the run comes from a fork, or the environment is not set on the job
"Resource not accessible by integration" GITHUB_TOKEN lacks a permission. Add it under permissions:
"Permission denied" pushing from a workflow Grant contents: write, and check branch protection rules
Checkout is missing files or history actions/checkout only fetches one commit by default. Add fetch-depth: 0 for full history and tags
Cache never hits The key changes each time, or it was saved on another branch. Check hashFiles paths, and use restore-keys
Cannot download an artifact Different run or job, wrong name, expired retention, or the v3 and v4 artifact actions mixed together
Windows job fails on shell syntax Default shell there is PowerShell. Set shell: bash, or write PowerShell
Job cancelled unexpectedly concurrency with cancel-in-progress, a timeout-minutes limit, or a manual cancel
Scheduled workflow never runs It must be on the default branch, cron is UTC, and it pauses after 60 days without activity
A workflow started by GITHUB_TOKEN does not trigger others By design. Use a GitHub App token or a personal access token if you need chaining
Deprecated set-output warning Switch to echo "name=value" >> "$GITHUB_OUTPUT"
Node 16 or node 20 deprecation warning Update the action to its latest major version
Out of minutes Check Settings > Billing. Reduce matrix size, cache dependencies, cancel duplicate runs and add timeouts
Tips
Start small: one workflow that checks out the code and runs your tests. Add jobs, a matrix and deployments as you need them.
Set permissions: contents: read at the top of every workflow, and raise it only on the jobs that need more.
Pin third-party actions to a commit SHA and keep them updated with Dependabot.
Never put untrusted text (issue titles, PR titles, branch names) straight into a run: step. Pass it through env: instead.
Use cache and concurrency to cut minutes: reuse dependencies, and cancel older runs when a newer push arrives.
Add timeout-minutes to every job so a hang cannot burn your allowance.
Use OIDC for cloud deployments and environments with required approval for production. Avoid long-lived keys in secrets.
Keep workflows readable: name every step, use reusable workflows and composite actions for repeated parts.
Lint with actionlint and test with act or a scratch branch before you rely on a workflow.
Read the first error in a failed log, re-run with debug logging when stuck, and check docs.github.com/actions for current syntax.