GitHub Actions — CI/CD
GitHub Actions is an event-driven CI/CD platform that automates software workflows directly in your GitHub repository. You can build, test, and deploy your code with reusable workflows triggered by GitHub events like pushes, pull requests, or scheduled intervals.
Actions run on GitHub-hosted runners (Linux, Windows, macOS) or self-hosted runners. Each workflow is defined in YAML and lives in the .github/workflows/ directory. This section covers workflow syntax, triggers, jobs, matrix builds, caching, secrets, artifacts, and reusable workflows.
Every workflow is a YAML file in .github/workflows/. The file name becomes the workflow name on GitHub. A workflow consists of triggers, jobs, and steps.
| 1 | # .github/workflows/ci.yml |
| 2 | name: Continuous Integration |
| 3 | |
| 4 | on: |
| 5 | push: |
| 6 | branches: [main, develop] |
| 7 | pull_request: |
| 8 | branches: [main] |
| 9 | |
| 10 | env: |
| 11 | NODE_VERSION: "20" |
| 12 | |
| 13 | jobs: |
| 14 | lint: |
| 15 | runs-on: ubuntu-latest |
| 16 | steps: |
| 17 | - uses: actions/checkout@v4 |
| 18 | - uses: actions/setup-node@v4 |
| 19 | with: |
| 20 | node-version: ${{ env.NODE_VERSION }} |
| 21 | - run: npm ci |
| 22 | - run: npm run lint |
| 23 | |
| 24 | test: |
| 25 | runs-on: ubuntu-latest |
| 26 | needs: lint |
| 27 | steps: |
| 28 | - uses: actions/checkout@v4 |
| 29 | - uses: actions/setup-node@v4 |
| 30 | with: |
| 31 | node-version: ${{ env.NODE_VERSION }} |
| 32 | - run: npm ci |
| 33 | - run: npm test |
| 34 | - run: npm run test:coverage |
| 35 | - uses: codecov/codecov-action@v4 |
| 36 | with: |
| 37 | token: ${{ secrets.CODECOV_TOKEN }} |
| 38 | |
| 39 | build: |
| 40 | runs-on: ubuntu-latest |
| 41 | needs: test |
| 42 | steps: |
| 43 | - uses: actions/checkout@v4 |
| 44 | - uses: actions/setup-node@v4 |
| 45 | with: |
| 46 | node-version: ${{ env.NODE_VERSION }} |
| 47 | - run: npm ci |
| 48 | - run: npm run build |
info
Triggers tell GitHub Actions when to run your workflow. You can use a single trigger or combine multiple triggers for complex scenarios.
| 1 | on: |
| 2 | # Push to any branch |
| 3 | push: |
| 4 | |
| 5 | # Push to specific branches |
| 6 | push: |
| 7 | branches: |
| 8 | - main |
| 9 | - "releases/**" |
| 10 | |
| 11 | # Pull request to main |
| 12 | pull_request: |
| 13 | branches: [main] |
| 14 | |
| 15 | # Scheduled (cron syntax) |
| 16 | schedule: |
| 17 | - cron: "0 6 * * 1-5" # Weekdays at 6 AM UTC |
| 18 | |
| 19 | # Manual trigger (workflow_dispatch) |
| 20 | workflow_dispatch: |
| 21 | inputs: |
| 22 | environment: |
| 23 | description: "Deploy environment" |
| 24 | required: true |
| 25 | default: "staging" |
| 26 | type: choice |
| 27 | options: |
| 28 | - staging |
| 29 | - production |
| 30 | |
| 31 | # On release publish |
| 32 | release: |
| 33 | types: [published] |
| 34 | |
| 35 | # On issue comment |
| 36 | issue_comment: |
| 37 | types: [created] |
| 38 | |
| 39 | # On label added to PR |
| 40 | pull_request: |
| 41 | types: [labeled] |
| 42 | |
| 43 | # Path filtering (only run if specific paths change) |
| 44 | push: |
| 45 | paths: |
| 46 | - "src/**" |
| 47 | - "!src/docs/**" |
| 48 | - ".github/workflows/**" |
best practice
Matrix strategies run a job across multiple combinations of variables. This is essential for testing across operating systems, language versions, and dependency configurations.
| 1 | jobs: |
| 2 | test: |
| 3 | runs-on: ${{ matrix.os }} |
| 4 | strategy: |
| 5 | matrix: |
| 6 | os: [ubuntu-latest, windows-latest, macos-latest] |
| 7 | node: [18, 20, 22] |
| 8 | include: |
| 9 | - os: ubuntu-latest |
| 10 | node: 20 |
| 11 | coverage: true |
| 12 | exclude: |
| 13 | - os: macos-latest |
| 14 | node: 18 |
| 15 | steps: |
| 16 | - uses: actions/checkout@v4 |
| 17 | - uses: actions/setup-node@v4 |
| 18 | with: |
| 19 | node-version: ${{ matrix.node }} |
| 20 | - run: npm ci |
| 21 | - run: npm test |
| 22 | - if: matrix.coverage |
| 23 | run: npm run test:coverage |
| 24 | |
| 25 | # Dynamic matrix from JSON output |
| 26 | deploy: |
| 27 | runs-on: ubuntu-latest |
| 28 | strategy: |
| 29 | matrix: |
| 30 | service: [api, web, worker] |
| 31 | steps: |
| 32 | - run: echo "Deploying ${{ matrix.service }}" |
Key matrix keywords:
| Keyword | Purpose |
|---|---|
| matrix | Define the variable combinations |
| include | Add extra combinations with additional variables |
| exclude | Remove specific combinations |
| fail-fast | Cancel all matrix jobs if one fails (default: true) |
| max-parallel | Limit concurrent matrix jobs |
pro tip
Caching speeds up workflows by reusing dependencies from previous runs. The actions/cache action stores and retrieves cached files based on a key.
| 1 | jobs: |
| 2 | test: |
| 3 | runs-on: ubuntu-latest |
| 4 | steps: |
| 5 | - uses: actions/checkout@v4 |
| 6 | |
| 7 | # Cache npm dependencies |
| 8 | - uses: actions/cache@v4 |
| 9 | id: npm-cache |
| 10 | with: |
| 11 | path: ~/.npm |
| 12 | key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }} |
| 13 | restore-keys: | |
| 14 | ${{ runner.os }}-node- |
| 15 | |
| 16 | # Install only if cache miss |
| 17 | - if: steps.npm-cache.outputs.cache-hit != 'true' |
| 18 | run: npm ci |
| 19 | |
| 20 | # Cache pip dependencies (Python) |
| 21 | - uses: actions/cache@v4 |
| 22 | with: |
| 23 | path: ~/.cache/pip |
| 24 | key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} |
| 25 | restore-keys: | |
| 26 | ${{ runner.os }}-pip- |
| 27 | |
| 28 | # Cache Gradle dependencies |
| 29 | - uses: actions/cache@v4 |
| 30 | with: |
| 31 | path: | |
| 32 | ~/.gradle/caches |
| 33 | ~/.gradle/wrapper |
| 34 | key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} |
| 35 | restore-keys: | |
| 36 | ${{ runner.os }}-gradle- |
| 37 | |
| 38 | # Cache Docker layers |
| 39 | - uses: actions/cache@v4 |
| 40 | with: |
| 41 | path: /tmp/.buildx-cache |
| 42 | key: ${{ runner.os }}-buildx-${{ github.sha }} |
| 43 | restore-keys: | |
| 44 | ${{ runner.os }}-buildx- |
info
Secrets are encrypted environment variables stored at the repository, environment, or organization level. They are masked in logs and never exposed to forks.
| 1 | jobs: |
| 2 | deploy: |
| 3 | runs-on: ubuntu-latest |
| 4 | environment: production |
| 5 | env: |
| 6 | APP_ENV: production |
| 7 | NODE_OPTIONS: --max-old-space-size=4096 |
| 8 | steps: |
| 9 | - uses: actions/checkout@v4 |
| 10 | |
| 11 | # Access repository secrets |
| 12 | - run: | |
| 13 | echo "${{ secrets.DEPLOY_KEY }}" > deploy-key.pem |
| 14 | chmod 600 deploy-key.pem |
| 15 | |
| 16 | # Environment-specific secrets |
| 17 | - run: ./deploy.sh |
| 18 | env: |
| 19 | API_TOKEN: ${{ secrets.API_TOKEN }} |
| 20 | DB_PASSWORD: ${{ secrets.DB_PASSWORD }} |
| 21 | |
| 22 | # Pass secrets as environment variables |
| 23 | - uses: some/action@v1 |
| 24 | with: |
| 25 | api-key: ${{ secrets.API_KEY }} |
| 26 | |
| 27 | # Use GitHub token (auto-generated) |
| 28 | - run: gh pr comment "$PR_URL" --body "Deployed!" |
| 29 | env: |
| 30 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 31 | |
| 32 | # Organization-level secret |
| 33 | - run: npm publish |
| 34 | env: |
| 35 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} |
| Secret Type | Scope | Configuration |
|---|---|---|
| Repository | Single repo | Settings > Secrets and variables > Actions |
| Environment | Specific deployment environment | Settings > Environments > <name> |
| Organization | All repos in org | Organization settings > Secrets and variables |
| GITHUB_TOKEN | Auto-generated per workflow run | Available as { secrets.GITHUB_TOKEN } |
warning
Artifacts are files produced by a workflow that you can download or pass between jobs. They persist for a configurable retention period (default 90 days).
| 1 | jobs: |
| 2 | build: |
| 3 | runs-on: ubuntu-latest |
| 4 | steps: |
| 5 | - uses: actions/checkout@v4 |
| 6 | - run: npm ci |
| 7 | - run: npm run build |
| 8 | |
| 9 | # Upload build output as artifact |
| 10 | - uses: actions/upload-artifact@v4 |
| 11 | with: |
| 12 | name: build-output |
| 13 | path: dist/ |
| 14 | if-no-files-found: error |
| 15 | retention-days: 7 |
| 16 | |
| 17 | deploy: |
| 18 | runs-on: ubuntu-latest |
| 19 | needs: build |
| 20 | steps: |
| 21 | # Download artifact from build job |
| 22 | - uses: actions/download-artifact@v4 |
| 23 | with: |
| 24 | name: build-output |
| 25 | path: dist/ |
| 26 | |
| 27 | - run: ./deploy.sh |
| 28 | |
| 29 | # Upload test results and reports |
| 30 | test: |
| 31 | runs-on: ubuntu-latest |
| 32 | steps: |
| 33 | - uses: actions/checkout@v4 |
| 34 | - run: npm ci |
| 35 | - run: npm test |
| 36 | |
| 37 | - uses: actions/upload-artifact@v4 |
| 38 | with: |
| 39 | name: test-results |
| 40 | path: | |
| 41 | coverage/ |
| 42 | test-results.xml |
| 43 | retention-days: 30 |
| 44 | |
| 45 | - uses: dorny/test-reporter@v1 |
| 46 | if: success() || failure() |
| 47 | with: |
| 48 | name: Jest Tests |
| 49 | path: test-results.xml |
| 50 | reporter: jest-junit |
pro tip
Reusable workflows (also called composite or called workflows) let you define a workflow once and call it from multiple other workflows. This is the foundation of DRY CI/CD.
| 1 | # .github/workflows/deploy-template.yml |
| 2 | # Reusable workflow with inputs and secrets |
| 3 | name: Deploy Template |
| 4 | on: |
| 5 | workflow_call: |
| 6 | inputs: |
| 7 | environment: |
| 8 | required: true |
| 9 | type: string |
| 10 | version: |
| 11 | required: true |
| 12 | type: string |
| 13 | secrets: |
| 14 | cloud_token: |
| 15 | required: true |
| 16 | |
| 17 | jobs: |
| 18 | deploy: |
| 19 | runs-on: ubuntu-latest |
| 20 | environment: ${{ inputs.environment }} |
| 21 | steps: |
| 22 | - uses: actions/checkout@v4 |
| 23 | - run: | |
| 24 | echo "Deploying version ${{ inputs.version }}" |
| 25 | echo "To environment: ${{ inputs.environment }}" |
| 26 | - run: ./deploy.sh ${{ inputs.version }} |
| 27 | env: |
| 28 | CLOUD_TOKEN: ${{ secrets.cloud_token }} |
| 29 | |
| 30 | --- |
| 31 | # .github/workflows/release.yml |
| 32 | # Caller workflow |
| 33 | name: Release |
| 34 | |
| 35 | on: |
| 36 | push: |
| 37 | tags: ["v*"] |
| 38 | |
| 39 | jobs: |
| 40 | deploy-staging: |
| 41 | uses: ./.github/workflows/deploy-template.yml |
| 42 | with: |
| 43 | environment: staging |
| 44 | version: ${{ github.ref_name }} |
| 45 | secrets: |
| 46 | cloud_token: ${{ secrets.STAGING_TOKEN }} |
| 47 | |
| 48 | deploy-production: |
| 49 | needs: deploy-staging |
| 50 | uses: ./.github/workflows/deploy-template.yml |
| 51 | with: |
| 52 | environment: production |
| 53 | version: ${{ github.ref_name }} |
| 54 | secrets: |
| 55 | cloud_token: ${{ secrets.PROD_TOKEN }} |
best practice
The GitHub Marketplace hosts thousands of community and verified actions for common tasks. Always check the action source, popularity, and maintenance status before using.
Essential Actions
| 1 | # Using marketplace actions |
| 2 | steps: |
| 3 | - uses: actions/checkout@v4 |
| 4 | |
| 5 | # Pin specific version (avoid @latest) |
| 6 | - uses: actions/setup-node@v4 |
| 7 | with: |
| 8 | node-version: "20" |
| 9 | |
| 10 | # Use Docker actions |
| 11 | - uses: docker/login-action@v3 |
| 12 | with: |
| 13 | registry: ghcr.io |
| 14 | username: ${{ github.actor }} |
| 15 | password: ${{ secrets.GITHUB_TOKEN }} |
| 16 | |
| 17 | - uses: docker/build-push-action@v5 |
| 18 | with: |
| 19 | context: . |
| 20 | push: true |
| 21 | tags: ghcr.io/${{ github.repository }}:${{ github.sha }} |
| 22 | |
| 23 | # Security scanning |
| 24 | - uses: github/codeql-action/init@v3 |
| 25 | with: |
| 26 | languages: javascript |
| 27 | - uses: github/codeql-action/analyze@v3 |
| 28 | |
| 29 | # Notifications |
| 30 | - uses: 8398a7/action-slack@v3 |
| 31 | with: |
| 32 | status: ${{ job.status }} |
| 33 | fields: repo,message,commit,author |
| 34 | if: always() |
warning
Self-hosted runners give you control over the execution environment. They are useful for accessing private networks, custom hardware, or specific OS configurations not available on GitHub-hosted runners.
| 1 | # Using self-hosted runners |
| 2 | jobs: |
| 3 | deploy: |
| 4 | runs-on: self-hosted |
| 5 | # Or use labels for specific runners: |
| 6 | # runs-on: [self-hosted, linux, gpu] |
| 7 | |
| 8 | steps: |
| 9 | - uses: actions/checkout@v4 |
| 10 | - run: ./deploy-internal.sh |
| 11 | |
| 12 | # Use runner groups |
| 13 | security-scan: |
| 14 | runs-on: [self-hosted, security-vm] |
| 15 | steps: |
| 16 | - run: ./vulnerability-scan.sh |
| 17 | |
| 18 | # Register a self-hosted runner: |
| 19 | # Settings > Actions > Runners > Add runner |
| 20 | # Follow the OS-specific instructions |
| 21 | # |
| 22 | # Key considerations: |
| 23 | # - Runners must have outbound HTTPS access to GitHub |
| 24 | # - Apply security patches regularly |
| 25 | # - Use runner groups for access control |
| 26 | # - Isolate runners per environment (dev/staging/prod) |
| 27 | # - Consider ephemeral runners (auto-terminate after use) |
warning
GitHub Actions supports expressions, functions, and workflow commands for dynamic configuration and logging.
| 1 | # Expressions and context objects |
| 2 | jobs: |
| 3 | example: |
| 4 | runs-on: ubuntu-latest |
| 5 | steps: |
| 6 | # Functions |
| 7 | - run: echo "Branch: ${{ github.ref_name }}" |
| 8 | - run: echo "SHA: ${{ github.sha }}" |
| 9 | - run: echo "Trigger: ${{ github.event_name }}" |
| 10 | |
| 11 | # Conditional execution |
| 12 | - run: echo "PR from fork" |
| 13 | if: github.event.pull_request.head.repo.fork |
| 14 | |
| 15 | # Status check functions |
| 16 | - run: echo "Always runs" |
| 17 | if: always() |
| 18 | - run: echo "Only on failure" |
| 19 | if: failure() |
| 20 | - run: echo "Only on success" |
| 21 | if: success() |
| 22 | |
| 23 | # Contains function |
| 24 | - run: echo "Branch name contains feature" |
| 25 | if: contains(github.ref_name, 'feature') |
| 26 | |
| 27 | # Formatting with expressions |
| 28 | - run: echo "Deploying ${{ format('v{0}.{1}.{2}', 1, 2, 3) }}" |
| 29 | |
| 30 | # Output variables between steps |
| 31 | - id: get-version |
| 32 | run: echo "version=1.0.0" >> $GITHUB_OUTPUT |
| 33 | - run: echo "Version is ${{ steps.get-version.outputs.version }}" |
pro tip
Concurrency controls prevent multiple workflow runs from overlapping. This is critical for deployment workflows where simultaneous runs could cause race conditions.
| 1 | # Cancel in-progress runs for the same PR/push |
| 2 | concurrency: |
| 3 | group: ${{ github.workflow }}-${{ github.ref }} |
| 4 | cancel-in-progress: true |
| 5 | |
| 6 | # Environment-specific concurrency |
| 7 | concurrency: |
| 8 | group: deploy-${{ inputs.environment }} |
| 9 | cancel-in-progress: false |
| 10 | |
| 11 | # Per-job concurrency |
| 12 | jobs: |
| 13 | deploy: |
| 14 | runs-on: ubuntu-latest |
| 15 | concurrency: |
| 16 | group: deploy-${{ github.ref_name }} |
| 17 | cancel-in-progress: false |
| 18 | steps: |
| 19 | - run: ./deploy.sh |
| 20 | |
| 21 | # Prevent concurrent deployments to the same env |
| 22 | concurrency: |
| 23 | group: ${{ github.workflow }}-${{ inputs.environment }} |
| 24 | cancel-in-progress: ${{ inputs.environment == 'staging' }} |
best practice
Debugging failing workflows requires inspecting logs, enabling debug logging, and understanding common failure patterns.
| 1 | # Enable verbose logging (repo secrets) |
| 2 | # Set these secrets: |
| 3 | # ACTIONS_STEP_DEBUG: true - Step-level debug |
| 4 | # ACTIONS_RUNNER_DEBUG: true - Runner-level debug |
| 5 | |
| 6 | # Debug job with SSH access (troubleshoot runners) |
| 7 | jobs: |
| 8 | debug: |
| 9 | runs-on: ubuntu-latest |
| 10 | steps: |
| 11 | - uses: actions/checkout@v4 |
| 12 | - uses: mxschmitt/action-tmate@v3 |
| 13 | with: |
| 14 | limit-access-to-actor: true |
| 15 | # This opens an SSH session to the runner. |
| 16 | # Connect with the URL shown in the logs. |
| 17 | |
| 18 | # Common debugging steps: |
| 19 | # - Use 'run: env' to print all environment variables |
| 20 | # - Use 'run: ls -la' to inspect file structure |
| 21 | # - Use 'run: pwd' to confirm working directory |
| 22 | # - Check runner OS: ${{ runner.os }} |
| 23 | # - Check available tools: run: which node && node --version |
info
✗ Expired secrets
Error: Resource not accessible by integrationSecrets can expire (e.g., cloud provider tokens). Rotate them regularly and use GitHub Environments for environment-specific secrets.
✗ Unnecessary CI runs
On: push (to all branches including docs/)Use path filters to skip CI for documentation, markdown, or config changes. This saves runner minutes and reduces feedback time for real changes.
✗ Hardcoded values instead of variables
run: echo "Deploying to us-east-1"Use variables and environment files for configurable values. Use vars context for non-sensitive configuration variables.
✗ Missing cache invalidation
key: ${{ runner.os }}-node-modulesCache keys without hashFiles never invalidate. Always include hashFiles('package-lock.json') or equivalent to automatically bust the cache when dependencies change.