GitHub Actions
What is GitHub Actions?
GitHub Actions is a CI/CD and automation platform built directly into GitHub. It lets you automate workflows — build, test, scan, deploy — triggered by any GitHub event.
WITHOUT GitHub Actions: WITH GitHub Actions:─────────────────────── ───────────────────Developer pushes code Developer pushes code ↓ ↓Manually run tests GitHub automatically: ↓ ├── runs testsManually build image ├── builds image ↓ ├── scans for vulnerabilitiesManually deploy ├── deploys to staging ↓ └── notifies teamHope nothing broke All in minutes, automatically
Core Concepts
┌─────────────────────────────────────────────────────────────┐│ GITHUB ACTIONS ││ ││ Event (trigger) ││ └──▶ Workflow (.github/workflows/deploy.yml) ││ └──▶ Job (runs on a runner) ││ └──▶ Steps ││ ├── Action (reusable unit) ││ └── Shell command (run:) │└─────────────────────────────────────────────────────────────┘
| Concept | What it is |
|---|---|
| Workflow | A YAML file defining automation (.github/workflows/) |
| Event | What triggers the workflow (push, PR, schedule, etc.) |
| Job | A set of steps that run on one runner |
| Step | A single task — action or shell command |
| Action | A reusable, pre-built step from marketplace |
| Runner | The machine that executes jobs (GitHub-hosted or self-hosted) |
| Secret | Encrypted variable stored in GitHub settings |
| Artifact | Files saved from a workflow run |
| Cache | Saved dependencies to speed up workflows |
Workflow File Structure
# .github/workflows/ci.yml# ── Workflow name ─────────────────────────────────────────────name: CI Pipeline# ── Triggers ─────────────────────────────────────────────────on: push: branches: [main, develop] pull_request: branches: [main] schedule: - cron: '0 8 * * 1-5' # weekdays at 8 AM UTC workflow_dispatch: # manual trigger button# ── Environment variables ─────────────────────────────────────env: APP_NAME: myapp REGISTRY: ghcr.io# ── Jobs ─────────────────────────────────────────────────────jobs: # Each key is a job ID build: name: Build and Test # display name runs-on: ubuntu-latest # runner type # Job-level environment env: NODE_ENV: test steps: # Each step has a name and action or run command - name: Checkout code uses: actions/checkoutv4 - name: Setup Node.js uses: actions/setup-nodev4 with: node-version: '20' cache: 'npm' - name: Install dependencies run: npm ci - name: Run tests run: npm test - name: Upload coverage uses: actions/upload-artifactv4 with: name: coverage-report path: coverage/
Triggers (Events)
on: # ── Code events ─────────────────────────────────────────── push: branches: - main - 'release/**' # wildcard tags: - 'v*' # version tags paths: - 'src/**' # only when these files change - '!docs/**' # exclude docs changes pull_request: types: - opened - synchronize # new commits pushed - reopened branches: - main pull_request_review: types: - submitted # ── Manual triggers ─────────────────────────────────────── workflow_dispatch: inputs: environment: description: 'Deploy to environment' required: true type: choice options: - dev - staging - production debug: description: 'Enable debug logging' type: boolean default: false # ── Scheduled ──────────────────────────────────────────── schedule: - cron: '0 2 * * *' # daily at 2 AM - cron: '0 8 * * 1' # monday at 8 AM # ── Other workflows ─────────────────────────────────────── workflow_call: # called by another workflow inputs: version: type: string required: true secrets: DEPLOY_KEY: required: true # ── Repository events ───────────────────────────────────── release: types: [published] issues: types: [opened, labeled] issue_comment: types: [created]
Runners
jobs: build: # ── GitHub-hosted runners ───────────────────────────── runs-on: ubuntu-latest # Ubuntu 22.04 runs-on: ubuntu-22.04 # specific version runs-on: windows-latest # Windows Server 2022 runs-on: macos-latest # macOS 13 # ── Self-hosted runners ─────────────────────────────── runs-on: self-hosted runs-on: [self-hosted, linux, x64] runs-on: [self-hosted, linux, gpu] # ── Matrix of runners ──────────────────────────────── strategy: matrix: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }}
GitHub-Hosted Runner Specs
| Runner | CPU | RAM | Storage |
|---|---|---|---|
ubuntu-latest | 2 cores | 7 GB | 14 GB |
windows-latest | 2 cores | 7 GB | 14 GB |
macos-latest | 3 cores | 14 GB | 14 GB |
ubuntu-latest (larger) | 4-64 cores | 16-256 GB | paid |
Steps — Actions vs Run
steps:# ── Using a pre-built Action ─────────────────────────────────- name: Checkout repository uses: actions/checkout@v4 # GitHub's official action with: fetch-depth: 0 # fetch all history# ── Running shell commands ────────────────────────────────────- name: Build application run: | echo "Building..." npm run build echo "Done"# ── Multi-line with specific shell ────────────────────────────- name: Run script shell: bash run: | set -euo pipefail echo "Error-safe script" ./deploy.sh# ── Python script ─────────────────────────────────────────────- name: Run Python run: | python3 -c " import json data = {'status': 'ok'} print(json.dumps(data)) "# ── Set environment variables for next steps ─────────────────- name: Set variables run: | echo "VERSION=1.2.3" >> $GITHUB_ENV echo "BUILD_DATE=$(date -u +%Y-%m-%d)" >> $GITHUB_ENV- name: Use variables run: echo "Version is $VERSION built on $BUILD_DATE"# ── Set step outputs ─────────────────────────────────────────- name: Get version id: version run: echo "tag=v1.2.3" >> $GITHUB_OUTPUT- name: Use output run: echo "Tag is ${{ steps.version.outputs.tag }}"# ── Conditional steps ─────────────────────────────────────────- name: Deploy to production if: github.ref == 'refs/heads/main' && github.event_name == 'push' run: ./deploy-prod.sh- name: Notify on failure if: failure() run: ./notify-team.sh- name: Always cleanup if: always() run: ./cleanup.sh# ── Continue on error ─────────────────────────────────────────- name: Optional check continue-on-error: true run: ./optional-scan.sh# ── Timeout ───────────────────────────────────────────────────- name: Long running test timeout-minutes: 30 run: ./integration-tests.sh
Jobs — Dependencies and Parallelism
jobs: # ── Parallel jobs (run at same time) ────────────────────── lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm run lint test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm test security: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: npm audit # ── Sequential job (waits for others) ──────────────────── build: runs-on: ubuntu-latest needs: [lint, test, security] # waits for all three steps: - uses: actions/checkout@v4 - run: npm run build # ── Deploy (waits for build) ────────────────────────────── deploy-staging: runs-on: ubuntu-latest needs: build steps: - run: ./deploy.sh staging deploy-prod: runs-on: ubuntu-latest needs: deploy-staging # sequential deploys steps: - run: ./deploy.sh production# Execution flow:# lint ──┐# test ──┼──▶ build ──▶ deploy-staging ──▶ deploy-prod# security ──┘
Job Outputs
jobs: get-version: runs-on: ubuntu-latest outputs: # declare outputs version: ${{ steps.ver.outputs.version }} sha: ${{ steps.ver.outputs.sha }} steps: - uses: actions/checkout@v4 - name: Get version id: ver run: | echo "version=$(cat VERSION)" >> $GITHUB_OUTPUT echo "sha=${GITHUB_SHA::8}" >> $GITHUB_OUTPUT build: needs: get-version runs-on: ubuntu-latest steps: - name: Use version run: | echo "Building version ${{ needs.get-version.outputs.version }}" echo "SHA: ${{ needs.get-version.outputs.sha }}"
Matrix Builds
Test across multiple versions/platforms simultaneously:
jobs: test: runs-on: ${{ matrix.os }} strategy: fail-fast: false # don't cancel others if one fails matrix: os: [ubuntu-latest, windows-latest, macos-latest] python: ['3.10', '3.11', '3.12'] exclude: - os: windows-latest python: '3.10' # skip this combination include: - os: ubuntu-latest python: '3.12' experimental: true # extra combination steps: - uses: actions/checkout@v4 - name: Setup Python ${{ matrix.python }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python }} - name: Run tests run: pytest tests/# Creates 8 parallel jobs:# ubuntu + 3.10, 3.11, 3.12# windows + 3.11, 3.12 (3.10 excluded)# macos + 3.10, 3.11, 3.12
Secrets and Variables
# Using secrets (encrypted)steps:- name: Deploy env: API_KEY: ${{ secrets.API_KEY }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} run: ./deploy.sh# Using variables (not encrypted)- name: Configure env: APP_ENV: ${{ vars.APP_ENV }} REGION: ${{ vars.AWS_REGION }} run: ./configure.sh# Built-in secrets- name: Push to registry run: | echo "${{ secrets.GITHUB_TOKEN }}" | \ docker login ghcr.io -u ${{ github.actor }} --password-stdin
Secret Scopes
Organization secrets → available to all repos in orgRepository secrets → available to one repoEnvironment secrets → available only in that environmentSettings → Secrets and variables → Actions
Environments and Approvals
jobs: deploy-prod: runs-on: ubuntu-latest environment: name: production # must be configured in Settings url: https://myapp.com # shown in GitHub UI steps: - name: Deploy run: ./deploy.sh production# In GitHub Settings → Environments → production:# ✅ Required reviewers: [senior-devops, team-lead]# ✅ Wait timer: 5 minutes# ✅ Deployment branches: main only# → Pipeline pauses and sends approval request# → Deployment only proceeds after approval
Caching Dependencies
steps:# ── Node.js / npm ─────────────────────────────────────────────- uses: actions/setup-nodev4 with: node-version: '20' cache: 'npm' # built-in cache# ── Manual cache control ──────────────────────────────────────- name: Cache node modules uses: actions/cachev4 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node-# ── Python / pip ──────────────────────────────────────────────- uses: actions/setup-pythonv5 with: python-version: '3.12' cache: 'pip'# ── Go modules ───────────────────────────────────────────────- uses: actions/setup-gov5 with: go-version: '1.21' cache: true# ── Docker layers ─────────────────────────────────────────────- name: Cache Docker layers uses: actions/cachev4 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx-
Artifacts
steps:# ── Upload artifact ───────────────────────────────────────────- name: Build run: npm run build- name: Upload build uses: actions/upload-artifact@v4 with: name: build-output path: dist/ retention-days: 7 # keep for 7 days# ── Upload test results ───────────────────────────────────────- name: Upload test results uses: actions/upload-artifact@v4 if: always() # upload even if tests fail with: name: test-results path: | test-results/ coverage/ *.xml# ── Download in another job ───────────────────────────────────jobs: build: steps: - name: Upload artifact uses: actions/upload-artifact@v4 with: name: app-binary path: bin/app deploy: needs: build steps: - name: Download artifact uses: actions/download-artifact@v4 with: name: app-binary path: bin/ - name: Deploy run: ./deploy.sh bin/app
Real-World Workflows
Full CI/CD Pipeline
# .github/workflows/cicd.ymlname: CI/CD Pipelineon: push: branches: [main] pull_request: branches: [main]env: REGISTRY: ghcr.io IMAGE: ghcr.io/${{ github.repository }}jobs: # ── 1. Code Quality ──────────────────────────────────────── quality: name: Code Quality runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' cache: pip - run: pip install -r requirements-dev.txt - name: Lint run: | flake8 src/ black --check src/ isort --check src/ - name: Type check run: mypy src/ # ── 2. Tests ────────────────────────────────────────────── test: name: Tests runs-on: ubuntu-latest needs: quality services: # spin up dependencies postgres: image: postgres:15 env: POSTGRES_PASSWORD: testpass POSTGRES_DB: testdb ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7 ports: - 6379:6379 steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: '3.12' cache: pip - run: pip install -r requirements.txt - name: Run tests with coverage env: DATABASE_URL: postgresql://postgres:testpass@localhost/testdb REDIS_URL: redis://localhost:6379 run: | pytest tests/ \ --cov=src \ --cov-report=xml \ --cov-report=html \ --junitxml=test-results.xml \ -v - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: file: coverage.xml - name: Upload test results uses: actions/upload-artifact@v4 if: always() with: name: test-results path: test-results.xml # ── 3. Security Scanning ────────────────────────────────── security: name: Security runs-on: ubuntu-latest needs: quality permissions: security-events: write steps: - uses: actions/checkout@v4 - name: Dependency audit run: pip-audit -r requirements.txt - name: SAST scan uses: github/codeql-action/init@v3 with: languages: python - uses: github/codeql-action/analyze@v3 - name: Secret scanning uses: trufflesecurity/trufflehog@main with: path: ./ base: ${{ github.event.repository.default_branch }} # ── 4. Build Image ──────────────────────────────────────── build: name: Build Image runs-on: ubuntu-latest needs: [test, security] permissions: contents: read packages: write outputs: image-digest: ${{ steps.build.outputs.digest }} image-tag: ${{ steps.meta.outputs.tags }} steps: - uses: actions/checkout@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Login to registry uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Docker metadata id: meta uses: docker/metadata-action@v5 with: images: ${{ env.IMAGE }} tags: | type=sha,prefix={{branch}}- type=ref,event=branch type=semver,pattern={{version}} type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push id: build uses: docker/build-push-action@v5 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max build-args: | VERSION=${{ github.sha }} BUILD_DATE=${{ github.event.head_commit.timestamp }} - name: Scan image for vulnerabilities uses: aquasecurity/trivy-action@master with: image-ref: ${{ env.IMAGE }}:${{ github.sha }} format: sarif output: trivy.sarif severity: CRITICAL,HIGH exit-code: '1' - name: Upload Trivy results uses: github/codeql-action/upload-sarif@v3 if: always() with: sarif_file: trivy.sarif # ── 5. Deploy Staging ───────────────────────────────────── deploy-staging: name: Deploy Staging runs-on: ubuntu-latest needs: build if: github.ref == 'refs/heads/main' environment: name: staging url: https://staging.myapp.com steps: - uses: actions/checkout@v4 - name: Auth to GCP uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.WIF_PROVIDER }} service_account: ${{ secrets.GKE_SA }} - name: Get GKE credentials uses: google-github-actions/get-gke-credentials@v2 with: cluster_name: staging-cluster location: us-central1 - name: Deploy to staging run: | kubectl set image deployment/api \ api=${{ env.IMAGE }}@${{ needs.build.outputs.image-digest }} \ -n staging kubectl rollout status deployment/api -n staging - name: Run smoke tests run: | sleep 30 curl -f https://staging.myapp.com/health # ── 6. Deploy Production ────────────────────────────────── deploy-prod: name: Deploy Production runs-on: ubuntu-latest needs: deploy-staging environment: name: production # requires approval url: https://myapp.com steps: - uses: actions/checkout@v4 - name: Auth to GCP uses: google-github-actions/auth@v2 with: workload_identity_provider: ${{ secrets.WIF_PROVIDER }} service_account: ${{ secrets.GKE_PROD_SA }} - name: Get GKE credentials uses: google-github-actions/get-gke-credentials@v2 with: cluster_name: prod-cluster location: us-central1 - name: Deploy to production run: | kubectl set image deployment/api \ api=${{ env.IMAGE }}@${{ needs.build.outputs.image-digest }} \ -n production kubectl rollout status deployment/api -n production \ --timeout=5m - name: Verify deployment run: | sleep 60 curl -f https://myapp.com/health - name: Notify success uses: slackapi/slack-github-action@v1.26.0 with: payload: | { "text": "✅ Deployed to production: ${{ github.sha }}" } env: SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }} - name: Rollback on failure if: failure() run: | kubectl rollout undo deployment/api -n production echo "🔴 Rolled back production deployment"
Reusable Workflows
# .github/workflows/reusable-deploy.yml# Called by other workflowsname: Reusable Deployon: workflow_call: inputs: environment: required: true type: string image-tag: required: true type: string secrets: DEPLOY_KEY: required: true outputs: deployment-url: value: ${{ jobs.deploy.outputs.url }}jobs: deploy: runs-on: ubuntu-latest environment: ${{ inputs.environment }} outputs: url: ${{ steps.deploy.outputs.url }} steps: - name: Deploy id: deploy run: | ./deploy.sh ${{ inputs.environment }} ${{ inputs.image-tag }} echo "url=https://${{ inputs.environment }}.myapp.com" >> $GITHUB_OUTPUT---# .github/workflows/main.yml# Calls the reusable workflowjobs: deploy-staging: uses: ./.github/workflows/reusable-deploy.yml with: environment: staging image-tag: ${{ needs.build.outputs.tag }} secrets: DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }} deploy-prod: needs: deploy-staging uses: ./.github/workflows/reusable-deploy.yml with: environment: production image-tag: ${{ needs.build.outputs.tag }} secrets: DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
Composite Actions
# .github/actions/setup-app/action.yml# Custom reusable actionname: Setup Applicationdescription: Install and configure the applicationinputs: python-version: description: Python version required: false default: '3.12' install-dev: description: Install dev dependencies required: false default: 'false'outputs: cache-hit: description: Whether cache was hit value: ${{ steps.cache.outputs.cache-hit }}runs: using: composite steps: - name: Setup Python uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} - name: Cache dependencies id: cache uses: actions/cache@v4 with: path: ~/.cache/pip key: pip-${{ inputs.python-version }}-${{ hashFiles('requirements*.txt') }} - name: Install dependencies shell: bash run: | pip install -r requirements.txt if [ "${{ inputs.install-dev }}" == "true" ]; then pip install -r requirements-dev.txt fi---# Use in workflowsteps:- uses: ./.github/actions/setup-app with: python-version: '3.12' install-dev: 'true'
Expressions and Context
# GitHub Contexts${{ github.sha }} # commit SHA${{ github.ref }} # branch/tag ref${{ github.event_name }} # push, pull_request, etc${{ github.actor }} # user who triggered${{ github.repository }} # owner/repo${{ github.run_id }} # unique run ID${{ github.run_number }} # sequential run number${{ runner.os }} # Linux, Windows, macOS${{ runner.temp }} # temp directory path${{ secrets.MY_SECRET }} # encrypted secret${{ vars.MY_VAR }} # variable (not encrypted)${{ env.MY_ENV_VAR }} # environment variable${{ steps.my-step.outputs.key }} # step output${{ needs.my-job.outputs.key }} # job output${{ needs.my-job.result }} # success/failure/etc# Expressions${{ 2 + 2 }} # math${{ 'hello' == 'hello' }} # comparison${{ contains(github.ref, 'main') }}${{ startsWith(github.ref, 'refs/tags/') }}${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}# Conditionalsif: github.ref == 'refs/heads/main'if: failure()if: success()if: always()if: cancelled()if: ${{ github.event.inputs.environment == 'production' }}
Popular Marketplace Actions
# Source control- uses: actions/checkout@v4- uses: actions/upload-artifact@v4- uses: actions/download-artifact@v4- uses: actions/cache@v4# Language setup- uses: actions/setup-node@v4- uses: actions/setup-python@v5- uses: actions/setup-go@v5- uses: actions/setup-java@v4# Docker- uses: docker/setup-buildx-action@v3- uses: docker/login-action@v3- uses: docker/build-push-action@v5- uses: docker/metadata-action@v5# Cloud providers- uses: google-github-actions/auth@v2- uses: google-github-actions/get-gke-credentials@v2- uses: azure/login@v1- uses: aws-actions/configure-aws-credentials@v4# Security- uses: aquasecurity/trivy-action@master- uses: bridgecrewio/checkov-action@master- uses: github/codeql-action/analyze@v3- uses: snyk/actions/node@master# Notifications- uses: slackapi/slack-github-action@v1- uses: peter-evans/create-issue-from-file@v5# Terraform- uses: hashicorp/setup-terraform@v3- uses: dflook/terraform-plan@v1- uses: dflook/terraform-apply@v1
Debugging Workflows
# Enable debug logging# Set secret: ACTIONS_STEP_DEBUG = true# Set secret: ACTIONS_RUNNER_DEBUG = truesteps:# Debug — print all context- name: Dump context env: GITHUB_CONTEXT: ${{ toJson(github) }} RUNNER_CONTEXT: ${{ toJson(runner) }} run: | echo "GitHub context:" echo "$GITHUB_CONTEXT" echo "Runner context:" echo "$RUNNER_CONTEXT"# SSH into runner for debugging- name: Setup tmate session uses: mxschmitt/action-tmate@v3 if: failure() # only on failure timeout-minutes: 15# Print environment- name: Debug environment run: | echo "PATH=$PATH" echo "PWD=$PWD" env | sort ls -la
Cost and Limits
GitHub-hosted runner minutes (free tier):├── Public repos: Unlimited free├── Private repos:│ ├── Free plan: 2,000 min/month│ ├── Pro: 3,000 min/month│ ├── Team: 3,000 min/month│ └── Enterprise: 50,000 min/monthMinute multipliers:├── Linux: 1x (1 minute = 1 minute)├── Windows: 2x (1 minute = 2 minutes)├── macOS: 10x (1 minute = 10 minutes)Other limits:├── Max workflow run time: 35 days├── Max job run time: 6 hours├── Max jobs per workflow: 500├── Max concurrent jobs: depends on plan├── Artifact storage: 500 MB (free), 2 GB (Pro)└── Cache storage: 10 GB per repo
Best Practices
Security: ✅ Pin action versions to SHA (not @v1 tag) uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 ✅ Use OIDC instead of long-lived credentials ✅ Minimal permissions on GITHUB_TOKEN ✅ Never echo secrets in logs ✅ Use environment secrets for productionPerformance: ✅ Cache dependencies aggressively ✅ Run jobs in parallel where possible ✅ Use path filters to skip unnecessary runs ✅ Fail fast on cheap checks (lint before test) ✅ Use Docker layer caching for buildsMaintainability: ✅ Use reusable workflows for repeated patterns ✅ Use composite actions for complex step groups ✅ Keep workflows focused — one workflow per concern ✅ Use concurrency groups to cancel stale runs ✅ Add workflow_dispatch for manual controlReliability: ✅ Set timeouts on all jobs and steps ✅ Always upload test results even on failure ✅ Use continue-on-error for non-blocking checks ✅ Add rollback steps on deployment failure ✅ Test workflows in feature branches
# Concurrency — cancel stale runsconcurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true # cancel old run when new one starts# Minimal permissionspermissions: contents: read # default — read only packages: write # only what you need security-events: write
GitHub Actions is the glue that holds modern DevOps together — it connects your code to every tool in your stack, automates every repetitive task, and enforces quality gates without slowing down your team. The YAML-based workflow model is powerful enough to handle any automation need while staying readable and maintainable.