GitHub Actions
Create, edit, debug, and optimize GitHub Actions workflows and custom actions.
Trigger
Activate when working with:
- Files in
.github/workflows/(workflow YAML files) - Files named
action.ymloraction.yaml(custom action definitions) - Any task involving CI/CD pipeline configuration for GitHub repositories
- Debugging failed workflow runs or action errors
Workflow Fundamentals
File Location
All workflow files must be in .github/workflows/. Subdirectories are not supported.
Minimal Workflow Structure
name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: echo "Hello"
Top-Level Keys
name-- display name in Actions tab (optional)run-name-- names the run, supportsgithubandinputscontexts (optional)on-- trigger event(s) (required)permissions-- GITHUB_TOKEN scopesenv-- workflow-level environment variablesdefaults.run-- defaultshellandworking-directoryforrunstepsconcurrency-- prevent duplicate runsjobs-- job definitions (required)
Events and Triggers
Common Trigger Patterns
# Branch and path filtering
on:
push:
branches: [main, 'releases/**']
paths: ['src/**']
paths-ignore: ['docs/**']
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
# Scheduled (cron)
on:
schedule:
- cron: '30 5 * * 1-5'
# Manual dispatch with typed inputs
on:
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
required: true
dry_run:
type: boolean
default: false
# API-triggered
on:
repository_dispatch:
types: [deploy]
# Chain workflows
on:
workflow_run:
workflows: ["Build"]
types: [completed]
branches: [main]
Input Types for workflow_dispatch
boolean, choice, number, environment, string. Max 25 inputs.
Filter Pattern Syntax
*matches any character except/**matches any character including/!negation (must follow a positive pattern)- Cannot mix
brancheswithbranches-ignore-- use!negation instead
Key pull_request Activity Types
opened, synchronize, reopened, closed, ready_for_review, labeled, unlabeled, review_requested, converted_to_draft
For a full event and activity type reference, consult references/syntax-reference.md.
Jobs
Job Configuration
jobs:
build:
runs-on: ubuntu-latest
needs: [lint, test] # dependencies
if: github.ref == 'refs/heads/main'
timeout-minutes: 30 # default: 360
continue-on-error: false
permissions:
contents: read
environment:
name: production
url: https://myapp.example.com
outputs:
version: ${{ steps.ver.outputs.value }}
env:
NODE_ENV: production
defaults:
run:
shell: bash
steps:
- uses: actions/checkout@v4
Steps
steps:
- id: step1
name: "Display Name"
if: success()
uses: actions/checkout@v4
with:
ref: main
env:
TOKEN: ${{ secrets.MY_TOKEN }}
continue-on-error: true
timeout-minutes: 10
- name: Run commands
run: |
npm install
npm test
shell: bash
working-directory: ./app
Setting Outputs
# Step output
- id: my_step
run: echo "result=value" >> $GITHUB_OUTPUT
# Multiline output (use heredoc delimiters)
- run: |
echo "json<<EOF" >> "$GITHUB_OUTPUT"
cat result.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
# Job output (declared at job level, references step)
jobs:
build:
outputs:
artifact-id: ${{ steps.upload.outputs.id }}
# Consume in dependent job
deploy:
needs: build
steps:
- run: echo "${{ needs.build.outputs.artifact-id }}"
Dynamic Environment Variables
- run: echo "MY_VAR=hello" >> $GITHUB_ENV
- run: echo "$MY_VAR" # available in subsequent steps
Job Summaries
- run: echo "### Build Results :rocket:" >> $GITHUB_STEP_SUMMARY
Matrix Strategies
strategy:
fail-fast: false
max-parallel: 4
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
node: [18, 20, 22]
include:
- os: ubuntu-latest
node: 16
experimental: true
exclude:
- os: macos-latest
node: 18
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.experimental == true }}
Dynamic Matrix from Job Output
jobs:
setup:
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- id: set
run: echo 'matrix=["a","b","c"]' >> "$GITHUB_OUTPUT"
run:
needs: setup
strategy:
matrix:
value: ${{ fromJSON(needs.setup.outputs.matrix) }}
Expressions and Contexts
Status Check Functions (for if: conditionals)
success()-- all previous steps succeeded (default)failure()-- any previous step failedalways()-- runs even if cancelledcancelled()-- workflow was cancelled
Use if: success() || failure() to run on both outcomes but skip cancellation.
Common Patterns
if: github.ref == 'refs/heads/main'
if: github.event_name == 'pull_request'
if: contains(github.event.pull_request.labels.*.name, 'deploy')
if: startsWith(github.ref, 'refs/tags/')
if: always() && needs.build.result == 'success'
# Ternary-like
value: ${{ github.event_name == 'push' && 'production' || 'staging' }}
Key Contexts
github.*-- workflow run info (ref,sha,actor,event,repository, etc.)env.*-- environment variablesvars.*-- configuration variables (repo/org/environment level)secrets.*-- secret valuessteps.<id>.outputs.*-- step outputsneeds.<job_id>.outputs.*-- dependent job outputsmatrix.*-- current matrix valuesrunner.*-- runner info (os,arch,temp)inputs.*-- workflow_dispatch/workflow_call inputs
Built-in Functions
contains(search, item),startsWith(),endsWith()-- case-insensitiveformat('{0} {1}', val0, val1)-- string formattingjoin(array, separator)-- default separator,toJSON(value),fromJSON(value)-- JSON serializationhashFiles(pattern)-- SHA-256 of matching filessuccess(),failure(),always(),cancelled()-- status checks
For complete context properties, consult references/syntax-reference.md.
Permissions
# Workflow-level (applies to all jobs by default)
permissions:
contents: read
pull-requests: write
# Job-level override
jobs:
deploy:
permissions:
contents: read
id-token: write
# Shorthand
permissions: read-all
permissions: {} # disable all
Available scopes: actions, attestations, checks, contents, deployments, discussions, id-token, issues, models, packages, pages, pull-requests, security-events, statuses.
Access levels: read, write, none.
Concurrency
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# For PRs (fallback when github.head_ref is undefined)
concurrency:
group: ${{ github.head_ref || github.run_id }}
cancel-in-progress: true
Always include github.workflow in the group name to avoid cross-workflow cancellation.
Caching
Prefer setup actions with built-in caching over raw actions/cache:
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
Manual caching:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
Secrets
# Use as environment variable (preferred -- avoids leaking via process args)
- env:
API_KEY: ${{ secrets.API_KEY }}
run: curl -H "Authorization: Bearer $API_KEY" https://api.example.com
# Pass as action input
- uses: some/action@v1
with:
token: ${{ secrets.MY_TOKEN }}
Key rules:
- Always pass secrets via
env:, never interpolate directly inrun:scripts - Secrets cannot be used in
if:conditionals -- set as env var first - Secrets are not passed to workflows triggered from forks
- Secrets must be explicitly passed to reusable workflows (or use
secrets: inherit) - GITHUB_TOKEN is automatic and scoped to the repo
Reusable Workflows
Define (workflow_call)
on:
workflow_call:
inputs:
environment:
required: true
type: string
secrets:
deploy_key:
required: true
outputs:
url:
value: ${{ jobs.deploy.outputs.url }}
Call
jobs:
deploy:
uses: ./.github/workflows/reusable-deploy.yml
with:
environment: staging
secrets:
deploy_key: ${{ secrets.STAGING_KEY }}
# Or pass all secrets
deploy-alt:
uses: org/repo/.github/workflows/deploy.yml@main
secrets: inherit
Limitations: max 9 levels nesting, no loops, uses must be at job level (not in steps).
Custom Actions
Three Types
| Type | Platform | Speed | runs.using |
|---|---|---|---|
| Composite | All | Fast | composite |
| JavaScript | All | Fast | node20 |
| Docker | Linux only | Slower | docker |
Composite Action (action.yml)
name: 'My Action'
description: 'Does something useful'
inputs:
name:
required: true
outputs:
result:
value: ${{ steps.run.outputs.result }}
runs:
using: composite
steps:
- id: run
run: echo "result=done" >> $GITHUB_OUTPUT
shell: bash # shell is MANDATORY on every run step
Referencing Actions
- uses: actions/checkout@8e5e7e5ab8b370d2c5cb812c4b306480e4a84920 # SHA (most secure)
- uses: actions/checkout@v4 # tag
- uses: ./.github/actions/my-action # local
- uses: docker://alpine:3.18 # Docker image
Deployment Environments
jobs:
deploy:
environment:
name: production
url: https://myapp.example.com
runs-on: ubuntu-latest
Features (configured in repo Settings > Environments): required reviewers, wait timers, deployment branch restrictions, environment-scoped secrets and variables.
Security Hardening
Critical Rules
- Pin actions to full SHA -- tags can be moved maliciously
- Use least-privilege permissions -- start with
permissions: {}, add back per-job - Never interpolate untrusted input in
run:scripts -- use intermediate env vars
Script Injection Prevention
# VULNERABLE
- run: echo "Title: ${{ github.event.pull_request.title }}"
# SECURE
- env:
TITLE: ${{ github.event.pull_request.title }}
run: echo "Title: $TITLE"
Dangerous Contexts (always treat as untrusted)
github.event.pull_request.title, github.event.pull_request.body, github.event.comment.body, github.event.issue.title, github.event.issue.body, github.head_ref, github.event.head_commit.message
Additional Security Measures
- Use OIDC for cloud auth instead of long-lived credentials
- Protect
.github/workflows/with CODEOWNERS - Enable Dependabot for action version updates
- Never use self-hosted runners for public repos
- Never store structured data (JSON/XML) as secrets -- redaction relies on exact string matching
Using gh CLI in Workflows
GitHub CLI is pre-installed on all runners. Set GH_TOKEN for auth:
- run: gh pr comment $PR --body "LGTM"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR: ${{ github.event.pull_request.number }}
References
For the complete syntax reference including all trigger events with activity types, all context properties, and detailed examples, consult references/syntax-reference.md.