GitHub Actions CI/CD Patterns
Intuition
Section titled “Intuition”A robot that tests and deploys for you: GitHub Actions is like hiring a diligent assistant who automatically runs your tests, builds your code, and deploys your app every time you push — catching mistakes before they reach users.
Why it matters: CI/CD automates the tedious parts of software development — testing, building, deploying — so you can focus on writing code. It also prevents the “works on my machine” problem by testing in a clean environment every time.
The key insight: The real power of CI/CD is fast feedback — if a test fails, you know within minutes, not days. This tight feedback loop dramatically reduces the cost of fixing bugs.
What is GitHub Actions
Section titled “What is GitHub Actions”GitHub Actions is an event-driven continuous integration and continuous deployment (CI/CD) platform Embedded directly into GitHub repositories. Workflows are defined as YAML files stored in .github/workflows/ and are triggered by repository events such as pushes, pull requests, issue Comments, scheduled cron expressions, and manual dispatches.
Core Concepts
Section titled “Core Concepts”| Concept | Description |
|---|---|
| Workflow | An automated process defined in a YAML file under .github/workflows/ |
| Event | A specific activity that triggers a workflow run (e.g., push``pull_request``schedule) |
| Job | A set of steps executed on the same runner; jobs run in parallel unless linked with needs |
| Step | An individual task within a job — either a run (shell command) or uses (action reference) |
| Action | A reusable unit of code packaged for use in workflows, sourced from the Marketplace or custom |
| Runner | The server that executes the workflow — GitHub-hosted (ubuntu-latest``windows-latest``macos-latest) or self-hosted |
| Artifact | A file or collection of files produced during a workflow run, persisted after the run completes |
| Secret | An encrypted environment variable accessible only within the workflow context |
Runner Taxonomy
Section titled “Runner Taxonomy”GitHub-hosted runners are ephemeral virtual machines provisioned per job and destroyed after Completion. They come in three operating system families:
| Runner identifier | OS | Architecture | Notes |
|---|---|---|---|
ubuntu-latest | Ubuntu 24.04 LTS | x64 | Most common; fastest startup |
ubuntu-24.04-arm | Ubuntu 24.04 | arm64 | For ARM-native workloads |
windows-latest | Windows Server 2022 | x64 | Uses PowerShell as default shell |
macos-latest | macOS 14 (Sonoma) | arm64 (M1) | 10× billing multiplier vs ubuntu |
macos-13 | macOS Ventura | x64 | Intel-based macOS |
Self-hosted runners allow execution on custom infrastructure and are not subject to the 6-hour job Timeout or IP egress restrictions, but require the operator to manage security, isolation, and Cleanup.
Actions Marketplace
Section titled “Actions Marketplace”The Actions Marketplace provides community-maintained And official actions. First-party actions live under the actions/ organization (e.g., actions/checkout@v4``actions/cache@v4``actions/upload-artifact@v4). Third-party actions should Be audited for supply-chain security before adoption.
Basic Workflow Structure
Section titled “Basic Workflow Structure”Every workflow file begins with name and on at the top level, followed by jobs. Each job has An identifier, runs on a runs-on runner, and contains an ordered list of steps.
Minimal Anatomy
Section titled “Minimal Anatomy”name: CI Pipelineon: push
jobs: build: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4
- name: Run a shell command run: echo "Hello from $GITHUB_SHA"Step Anatomy
Section titled “Step Anatomy”A step is either an action reference (uses) or a shell command (run). The two are mutually Exclusive within a single step.
| Key | Purpose |
|---|---|
name | Human-readable label displayed in the workflow run log |
uses | Reference to an action (owner/repo@ref or ./local/path) |
run | Shell command to execute; shell defaults to bash on Linux/macOS, pwsh on Windows |
with | Input parameters passed to an uses action |
env | Environment variables scoped to the step |
if | Conditional expression; step is skipped when the expression evaluates to false |
id | Unique identifier so subsequent steps can reference outputs via steps.<id>.outputs |
timeout-minutes | Maximum time before the step is killed (overrides job-level timeout) |
continue-on-error | If trueThe workflow treats a failure in this step as a success |
Environment Variables
Section titled “Environment Variables”Variables can be set at four levels of decreasing precedence: step-level envJob-level env Workflow-level envAnd runner-level environment.
name: Environment Variables Demoon: push
env: GLOBAL_VAR: "set at workflow level'
jobs: demo: runs-on: ubuntu-latest env: JOB_VAR: "set at job level'' steps: - name: Step-level override env: STEP_VAR: "set at step level' run: | echo "GLOBAL_VAR=$GLOBAL_VAR" echo "JOB_VAR=$JOB_VAR" echo "STEP_VAR=$STEP_VAR" echo "GITHUB_SHA=$GITHUB_SHA" echo "RUNNER_OS=$RUNNER_OS"Complete Lint-and-Test Example
Section titled “Complete Lint-and-Test Example”name: Lint and Teston: push: branches: [main] pull_request: branches: [main]
jobs: lint-and-test: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }}
- name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements-dev.txt
- name: Lint with ruff run: ruff check .
- name: Type check with mypy run: mypy src/
- name: Run tests run: pytest --cov=src --cov-report=xml --junitxml=test-results.xml
- name: Upload coverage if: matrix.python-version == '3.13' uses: actions/upload-artifact@v4 with: name: coverage-report path: coverage.xmlTrigger Patterns
Section titled “Trigger Patterns”The on key defines what events activate the workflow. Triggers can be filtered by branch, tag, Path, file type, or event type.
Push and Pull Request Triggers
Section titled “Push and Pull Request Triggers”on: push: branches: [main, develop] tags: ['v*']
pull_request: types: [opened, synchronize, reopened] branches: [main]The synchronize type fires when the source branch of a pull request is updated (new commits Pushed). This is critical for ensuring that CI re-runs when additional commits are added.
Path Filtering
Section titled “Path Filtering”Path filters limit execution to changes within specific directories or file patterns. A workflow With paths triggers only when at least one modified file matches the filter.
on: pull_request: paths: - 'src/**' - 'tests/**' - 'pyproject.toml' - 'requirements*.txt'
push: paths-ignore: - '**.md' - 'docs/**' - '.github/workflows/docs.yml'Path filtering operates on the full diff against the base ref. On a pull request, the base is The target branch. On a push, the base is the previous commit on the same branch. For the first Commit on a new branch, all files are considered changed.
Scheduled Triggers (Cron)
Section titled “Scheduled Triggers (Cron)”on: schedule: - cron: "0 0 * * 1'' # Every Monday at 00:00 UTC - cron: "0 6 * * 1-5' # Weekdays at 06:00 UTCCron syntax follows POSIX: minute hour day-of-month month day-of-week. GitHub Actions cron is not Guaranteed to run at the exact minute — scheduled workflows are queued and may be delayed under Heavy load. If a scheduled run is skipped due to infrastructure issues, GitHub does not re-schedule It.
Manual Dispatch
Section titled “Manual Dispatch”on: workflow_dispatch: inputs: environment:description: "GitHub Actions is an event-driven continuous integration and continuous deployment (CI/CD) platform Embedded directly into GitHub repositories. Workflows..." required: true default: "staging' type: choice options: - staging - production debug: description: "Enable debug logging'' required: false type: boolean default: falseThe workflow_dispatch event enables a “Run workflow” button in the GitHub Actions tab. Input Values are accessible via ${{ github.event.inputs.environment }} (legacy) or ${{ inputs.environment }} (preferred in newer API versions).
Repository Dispatch
Section titled “Repository Dispatch”on: repository_dispatch: types: [deploy-trigger, rebuild-cache]Repository dispatch allows one workflow to trigger another via the GitHub API (POST /repos/{owner}/{repo}/dispatches). The receiving workflow filters on types. This is the Primary mechanism for cross-repository orchestration in monorepo setups.
Trigger Combinations
Section titled “Trigger Combinations”Multiple events can be combined in a single workflow:
on: push: branches: [main] pull_request: branches: [main] schedule: - cron: "0 0 * * *' workflow_dispatch:When combining push and pull_request triggers, use ${{ github.event_name }} to differentiate Behavior at runtime.
Job Dependencies
Section titled “Job Dependencies”By default, all jobs in a workflow run in parallel. The needs keyword creates a directed acyclic Graph (DAG) of dependencies.
Sequential Jobs
Section titled “Sequential Jobs”jobs: build: runs-on: ubuntu-latest steps: - run: echo "Building..."
test: needs: build runs-on: ubuntu-latest steps: - run: echo "Testing..."The test job only begins after build completes successfully.
Fan-In Pattern
Section titled “Fan-In Pattern”Multiple jobs can converge into a single downstream job:
jobs: lint: runs-on: ubuntu-latest steps: - run: echo "Linting..."
unit-test: runs-on: ubuntu-latest steps: - run: echo "Unit tests..."
integration-test: runs-on: ubuntu-latest steps: - run: echo "Integration tests..."
deploy: needs: [lint, unit-test, integration-test] runs-on: ubuntu-latest if: success() steps: - run: echo "Deploying..."deploy waits for all three upstream jobs. If any upstream job fails, deploy is skipped by Default (implicit if: success()).
Conditional Execution on Upstream Status
Section titled “Conditional Execution on Upstream Status”jobs: build: runs-on: ubuntu-latest steps: - run: exit 1 name: Simulate failure
notify-on-failure: needs: build if: failure() runs-on: ubuntu-latest steps: - run: echo "Build failed, sending notification"
always-run: needs: build if: always() runs-on: ubuntu-latest steps: - run: echo "This always runs"| Condition function | Meaning |
|---|---|
success() | All dependency jobs succeeded (default) |
failure() | At least one dependency job failed |
always() | Run regardless of dependency job outcomes |
cancelled() | The workflow was cancelled |
Concurrency Control
Section titled “Concurrency Control”Concurrency groups ensure that only one workflow run (or job) proceeds at a time within a named Group. This is essential for preventing race conditions in deployment workflows.
concurrency: group: deploy-${{ github.ref }} cancel-in-progress: true
jobs: deploy: runs-on: ubuntu-latest steps: - run: echo "Deploying to ${{ github.ref }}"When a new run is triggered with the same concurrency group key, cancel-in-progress: true aborts Any in-progress run sharing that key. Setting it to false (the default) queues the new run until The existing one completes.
For pull requests, use the PR number to scope concurrency:
concurrency: group: ci-${{ github.event.pull_request.number }} cancel-in-progress: trueThis ensures each PR gets its own concurrency slot, while pushes to the same branch are serialized.
Caching
Section titled “Caching”Caching intermediate build artifacts (dependencies, compiled objects) across workflow runs Dramatically reduces execution time. GitHub Actions provides the actions/cache@v4 action for this Purpose.
Cache Key Strategy
Section titled “Cache Key Strategy”- uses: actions/cache@v4 with: path: ~/.cache/pip key: pip-${{ runner.os }}-${{ hashFiles('**/requirements*.txt') }} restore-keys: | pip-${{ runner.os }}- pip-| Key component | Purpose |
|---|---|
key | Primary cache key; exact match required |
restore-keys | Fallback prefix keys used when no exact match is found |
path | File or directory to cache |
Cache lookup proceeds as follows:
- An exact match for
keyis sought. If found, the cache is restored and marked as a cache hit. - If no exact match,
restore-keysare searched in order by most recent creation date. The first match is restored and marked as a partial hit. - If no match at all, no cache is restored (cache miss). A new cache entry is written at the end of the job using the
key.
Language-Specific Cache Patterns
Section titled “Language-Specific Cache Patterns”Python (pip):
- name: Cache pip uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }} restore-keys: ${{ runner.os }}-pip-Python (uv):
- name: Cache uv uses: actions/cache@v4 with: path: ~/.cache/uv key: ${{ runner.os }}-uv-${{ hashFiles('**/uv.lock') }}Node.js (npm):
- name: Cache npm uses: actions/cache@v4 with: path: ~/.npm key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }} restore-keys: ${{ runner.os }}-npm-Rust (cargo):
- name: Cache cargo registry and build uses: actions/cache@v4 with: path: | ~/.cargo/registry ~/.cargo/git target key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} restore-keys: ${{ runner.os }}-cargo-Go (Go modules):
- name: Cache Go modules uses: actions/cache@v4 with: path: | ~/go/pkg/mod ~/.cache/go-build key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} restore-keys: ${{ runner.os }}-go-Cache Invalidation
Section titled “Cache Invalidation”Caches are immutable once written. To invalidate, change the key — by bumping a version Prefix:
key: v2-${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}GitHub evicts repository caches using a least-recently-used (LRU) policy when the total cache size Exceeds 10 GB. Branches can only access caches from the default branch or the same branch.
Artifacts
Section titled “Artifacts”Artifacts are persistent files produced during a workflow run. Unlike caches, artifacts are intended For human consumption or cross-job data transfer and are retained after the run completes.
Uploading Artifacts
Section titled “Uploading Artifacts”- name: Upload build output uses: actions/upload-artifact@v4 with: name: build-artifacts path: dist/ retention-days: 14 if-no-files-found: error compression-level: 6| Parameter | Description |
|---|---|
name | Artifact name (unique within the run) |
path | File or directory to upload; supports glob patterns |
retention-days | Days to retain (default 90, max 90 on free plans) |
if-no-files-found | Behavior when no files match: warn``error``ignore |
compression-level | 0 (none) to 9 (max); default 6 |
Downloading Artifacts
Section titled “Downloading Artifacts”- name: Download all artifacts uses: actions/download-artifact@v4 with: path: all-artifacts/ merge-multiple: trueTo download a specific artifact:
- name: Download build artifacts uses: actions/download-artifact@v4 with: name: build-artifacts path: dist/Sharing Data Between Jobs
Section titled “Sharing Data Between Jobs”Artifacts are the primary mechanism for passing data between jobs (since each job runs on a fresh Runner):
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make build - uses: actions/upload-artifact@v4 with: name: binary path: bin/app
test: needs: build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 with: name: binary path: bin/ - run: ./bin/app --run-testsArtifact Size Limits
Section titled “Artifact Size Limits”| Limit | Value |
|---|---|
| Single artifact max size | 5 GB |
| Total artifacts per run | No hard cap (storage billing applies) |
| Max number of artifacts per run | 500 |
Common CI Patterns
Section titled “Common CI Patterns”Linting and Type Checking
Section titled “Linting and Type Checking”Linting should be a fast, early gate in the pipeline. Run linters and type checkers in parallel when Possible.
Python (ruff + mypy):
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.13'' - run: pip install ruff mypy - run: ruff check --output-format=github . - run: ruff format --check . - run: mypy src/JavaScript/TypeScript (eslint):
jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: node-version: "20' cache: npm - run: npm ci - run: npx eslint . --format githubThe --output-format=github (ruff) and --format github (eslint) flags emit annotations that GitHub renders inline in pull request diffs.
Running Tests with Coverage
Section titled “Running Tests with Coverage”jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.13'' - run: pip install -e ".[dev]" - name: Run tests with coverage run: | pytest \ --cov=src \ --cov-report=term-missing \ --cov-report=xml:coverage.xml \ --junitxml=test-results.xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 with: files: coverage.xml fail_ci_if_error: trueSecurity Scanning
Section titled “Security Scanning”jobs: security: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Run Trivy vulnerability scanner uses: aquasecurity/trivy-action@master with: scan-type: "fs' scan-ref: ".'' severity: "CRITICAL,HIGH' exit-code: 1
- name: Python dependency audit run: | pip install pip-audit pip-audit -r requirements.txt
- name: npm audit run: npm audit --audit-level=highBranch Protection Integration
Section titled “Branch Protection Integration”Branch protection rules can require specific workflow jobs to pass before allowing a merge. To use This:
- Navigate to Settings > Branches > Branch protection rules
- Select the target branch (e.g.,
main) - Enable Require status checks to pass before merging
- Add the job names as required checks (e.g.,
lint``test``security)
Jobs must have unique names across the workflow for status checks to identify them correctly.
Matrix Testing
Section titled “Matrix Testing”Matrix strategies run a job multiple times with different variable combinations. This is the Standard approach for testing across language versions, operating systems, or dependency versions.
jobs: test: runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ['3.11', '3.12', '3.13'] exclude: - os: macos-latest python-version: "3.11'' include: - os: ubuntu-latest python-version: "3.13' experimental: true steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - run: python --version| Matrix option | Purpose |
|---|---|
fail-fast | If true (default), cancel all in-progress jobs on first failure |
exclude | Remove specific combinations from the matrix |
include | Add specific combinations to the matrix |
max-parallel | Limit the number of concurrent matrix job runs |
CD/Deployment Patterns
Section titled “CD/Deployment Patterns”Deploy to GitHub Pages
Section titled “Deploy to GitHub Pages”jobs: build-and-deploy: runs-on: ubuntu-latest permissions: contents: read pages: write id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4
- name: Build site run: npm run build
- name: Setup Pages uses: actions/configure-pages@v5
- name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: build/
- name: Deploy to Pages id: deployment uses: actions/deploy-pages@v4Docker Build and Push
Section titled “Docker Build and Push”jobs: docker: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
- name: Set up Docker Buildx uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata id: meta uses: docker/metadata-action@v5 with: images: ghcr.io/${{ github.repository }} tags: | type=ref,event=branch type=semver,pattern={{version}} type=sha
- name: Build and push uses: docker/build-push-action@v6 with: context: . push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=maxRelease Automation
Section titled “Release Automation”jobs: release: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/v') permissions: contents: write steps: - uses: actions/checkout@v4
- name: Build distribution run: python -m build
- name: Create GitHub Release uses: softprops/action-gh-release@v2 with: generate_release_notes: true files: dist/*
- name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: password: ${{ secrets.PYPI_API_TOKEN }}Blue-Green Deployment
Section titled “Blue-Green Deployment”Blue-green deployment maintains two identical production environments. At any time, one is active (“blue”) and the other is idle (“green”). A new release deploys to the idle environment, and traffic Is switched atomically.
jobs: deploy-green: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Deploy to green environment run: | aws s3 sync dist/ s3://app-green-${{ vars.ENVIRONMENT }}/ echo "Deployed to green"
switch-traffic: needs: deploy-green runs-on: ubuntu-latest steps: - name: Health check on green run: | curl --fail --retry 5 --retry-delay 10 \ https://green.${{ vars.DOMAIN }}/healthz
- name: Switch traffic to green run: | aws route53 change-resource-record-sets \ --hosted-zone-id ${{ secrets.HOSTED_ZONE_ID }} \ --change-batch '{ "Changes": [{ "Action": "UPSERT", "ResourceRecordSet": { "Name": "app.${{ vars.DOMAIN }}", "Type": "CNAME", "TTL": 60, "ResourceRecords": [{"Value": "green.${{ vars.DOMAIN }}"}] } }] }'Canary Deployment
Section titled “Canary Deployment”Canary deployment routes a small percentage of traffic to the new version, monitors for errors, and Gradually increases traffic if metrics are healthy.
jobs: deploy-canary: runs-on: ubuntu-latest steps: - name: Deploy canary (5% traffic) run: | kubectl apply -f k8s/canary-deployment.yaml kubectl apply -f k8s/canary-service.yaml
promote: needs: deploy-canary runs-on: ubuntu-latest steps: - name: Monitor error rate run: | sleep 300 ERROR_RATE=$(curl -s "${{ vars.MONITORING_URL }}/error-rate" || echo "100") if (( $(echo "$ERROR_RATE > 1.0" | bc -l) )); then echo "Error rate too high: $ERROR_RATE%" kubectl delete -f k8s/canary-deployment.yaml exit 1 fi
- name: Promote canary to full rollout run: | kubectl apply -f k8s/production-deployment.yaml kubectl delete -f k8s/canary-deployment.yamlReusable Workflows
Section titled “Reusable Workflows”Reusable workflows allow teams to centralize workflow logic in a single file and reference it from Multiple workflows, potentially across repositories.
Calling a Reusable Workflow
Section titled “Calling a Reusable Workflow”jobs: call-lint: uses: ./.github/workflows/reusable-lint.yml with: python-version: "3.13'' secrets: inheritDefining a Reusable Workflow
Section titled “Defining a Reusable Workflow”name: Reusable Lint Workflowon: workflow_call: inputs: python-version: description: "Python version to use"' required: false type: string default: "3.13' fail-on-error: description: "Fail the workflow on lint errors'' required: false type: boolean default: true outputs: lint-result: description: "Lint result status' value: ${{ jobs.lint.outputs.result }} secrets: optional-secret: description: "An optional secret'' required: false
jobs: lint: runs-on: ubuntu-latest outputs: result: ${{ steps.lint-step.outputs.exit_code }} steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} - run: pip install ruff mypy - id: lint-step run: | ruff check . 2>&1 | tee lint-output.txt echo "exit_code=$?" >> $GITHUB_OUTPUTCross-Repository Reusable Workflows
Section titled “Cross-Repository Reusable Workflows”A workflow in repository A can call a reusable workflow in repository B:
jobs: call-external: uses: my-org/shared-ci/.github/workflows/lint.yml@main with: python-version: "3.12' secrets: inheritThe calling repository must have access to the source repository, and the secrets: inherit keyword Passes all repository and organization secrets to the reusable workflow.
Composite Actions
Section titled “Composite Actions”For reusable logic smaller than a full workflow, composite actions package multiple steps into a Single action defined by an action.yml file:
name: "Setup Python Environment''description: "Set up Python with caching"inputs: python-version: description: "Python version' required: trueruns: using: "composite'' steps: - uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} - shell: bash run: | python -m pip install --upgrade pip pip install -r requirements-dev.txtUse in a workflow:
steps: - uses: ./.github/actions/setup-python-env with: python-version: "3.13'Secrets Management
Section titled “Secrets Management”GitHub Secrets
Section titled “GitHub Secrets”Secrets are encrypted environment variables stored at the repository, environment, or organization Level. They are decrypted and injected into the workflow runtime and are not exposed to forked Repository pull requests.
jobs: deploy: runs-on: ubuntu-latest steps: - name: Deploy with secret env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: aws s3 sync dist/ s3://${{ vars.BUCKET_NAME }}/Secret Scoping
Section titled “Secret Scoping”| Scope | Visibility | Use case |
|---|---|---|
| Repository | All workflows in the repository | Deploy credentials, API tokens |
| Environment | Workflows using that environment | Per-environment secrets (staging vs prod) |
| Organization | All repositories in the organization | Shared SSO tokens, registry credentials |
Environment-level secrets require the job to specify the environment key:
jobs: deploy-prod: runs-on: ubuntu-latest environment: name: production url: https://app.example.com steps: - run: deploy --token ${{ secrets.PROD_API_KEY }}GITHUB_TOKEN
Section titled “GITHUB_TOKEN”GitHub automatically provides a GITHUB_TOKEN secret on every workflow run. This is a short-lived (job-scoped) token that authenticates API calls back to the repository.
jobs: build: runs-on: ubuntu-latest permissions: contents: read packages: write steps: - uses: actions/checkout@v4 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Push to registry run: | echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdinDefault GITHUB_TOKEN permissions are read-only. To grant write access, declare permissions at The workflow or job level:
| Permission | Scope |
|---|---|
contents | Repository content (commits, releases) |
pull-requests | Pull request operations |
issues | Issue operations |
packages | Container and package registry |
id-token | OIDC token generation (for cloud auth) |
actions | Workflow operations |
pages | GitHub Pages deployment |
Variables vs Secrets
Section titled “Variables vs Secrets”Variables (vars) are non-secret, plain-text configuration values stored alongside secrets. They Are appropriate for non-sensitive configuration such as deployment URLs, environment names, or Feature flags.
env: DEPLOY_TARGET: ${{ vars.DEPLOY_TARGET }} API_ENDPOINT: ${{ vars.API_ENDPOINT }}Variables are visible to anyone with read access to the repository, unlike secrets which are Write-only.
Environment Protection Rules
Section titled “Environment Protection Rules”Environment protection rules add governance gates that must be satisfied before a job targeting that Environment can proceed.
Required Reviewers
Section titled “Required Reviewers”jobs: deploy-production: runs-on: ubuntu-latest environment: name: production steps: - run: echo "This waits for approval"When configured, the workflow run pauses in a “waiting” state until at least one required reviewer Approves. Reviewers must have write access to the repository.
Required Status Checks
Section titled “Required Status Checks”Branch protection can require specific workflow jobs to pass. Combine this with environment Protection for defense-in-depth:
- Branch protection: requires
test``lintAndsecurityjobs to pass - Environment protection: requires manual approval for the
productionenvironment
Wait Timer
Section titled “Wait Timer”A wait timer introduces a mandatory delay between the workflow trigger and job execution:
- Configured in Settings > Environments > [environment name] > Protection rules
- Range: 0 to 43,200 minutes (30 days)
- Use case: cool-down period before production deployments, allowing reviewers time to inspect changes
Branch Restrictions
Section titled “Branch Restrictions”Deployment protection can be combined with branch restrictions to ensure only specific branches can Deploy to an environment:
- Settings > Environments > production > Deployment branches and tags
- Select “Selected branches and tags” and choose the allowed branches (e.g.,
main``release/*)
Deployment Protection with OIDC
Section titled “Deployment Protection with OIDC”GitHub Actions supports OIDC (OpenID Connect) for authentication to cloud providers without storing Long-lived credentials:
jobs: deploy: runs-on: ubuntu-latest permissions: id-token: write contents: read steps: - name: Configure AWS credentials via OIDC uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: ${{ secrets.AWS_ROLE_ARN }} role-session-name: github-actions-${{ github.run_id }} aws-region: us-east-1
- name: Deploy run: aws s3 sync dist/ s3://my-bucket/The OIDC exchange eliminates the need for AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY secrets Entirely, replacing them with a short-lived token scoped to the specific workflow run.
Common Pitfalls
Section titled “Common Pitfalls”Shell Quoting in YAML
Section titled “Shell Quoting in YAML”Multiline run blocks in YAML are prone to quoting issues. The pipe (|) operator preserves Newlines and is the safest choice for multi-line scripts:
# Correct: pipe preserves newlines- run: | echo "line 1" echo "line 2"
# Dangerous: > folds newlines into spaces- run: > echo "line 1" echo "line 2"
# Correct: single-line with explicit semicolons- run: echo "step 1" && echo "step 2"Embedding JSON inside YAML requires extra care with nested quotes. Use the > operator with Explicit escaping or pipe to a heredoc:
- run: | curl -X POST https://api.example.com \ -H "Content-Type: application/json" \ -d '{"key": "value"}'Windows/Linux Path Differences
Section titled “Windows/Linux Path Differences”Windows uses backslash separators and drive letters (C:\). Linux/macOS use forward slashes (/). Hardcoded paths break cross-platform workflows.
# Fragile — breaks on Windows- run: rm -rf build/
# Cross-platform using GitHub environment variables- run: | echo "$HOME" # Works on Linux/macOS echo "$USERPROFILE" # Windows equivalent echo "$GITHUB_WORKSPACE" # Works on all platformsUse actions/cache and actions/upload-artifact path inputs, which handle path normalization Internally. Avoid constructing paths manually.
GITHUB_TOKEN Permissions
Section titled “GITHUB_TOKEN Permissions”The default GITHUB_TOKEN has read-only permissions. If a workflow needs to push commits, create Releases, or write to the package registry, permissions must be explicitly declared:
# At the top level (applies to all jobs)permissions: contents: write pull-requests: write
# Or per-jobjobs: release: runs-on: ubuntu-latest permissions: contents: writeOn pull requests from forked repositories, the GITHUB_TOKEN is read-only and severely Restricted regardless of the permissions declaration. This is a security measure to prevent fork PRs From exfiltrating secrets or modifying the upstream repository.
Action Version Pinning
Section titled “Action Version Pinning”Pinning actions by tag (e.g., @v4) is convenient but vulnerable to supply-chain attacks if the tag Is moved. Pinning by commit SHA is the most secure approach:
# Less secure: tag-based (mutable)- uses: actions/checkout@v4
# Most secure: SHA-based (immutable)- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683However, SHA-based pinning makes updates manual and opaque. A balanced approach uses dependabot to Automatically update action versions:
version: 2updates: - package-ecosystem: "github-actions'' directory: "/' schedule: interval: 'weekly'Workflow Timeout
Section titled “Workflow Timeout”Jobs have a default timeout of 360 minutes (6 hours) on GitHub-hosted runners. Set an explicit timeout-minutes at the job level to catch hung processes:
jobs: test: runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Run test suite timeout-minutes: 20 run: pytestRunner Resource Limits
Section titled “Runner Resource Limits”GitHub-hosted runners have finite resources:
| Resource | Limit (2-core runner) | Limit (larger runners) |
|---|---|---|
| CPU | 2 cores | Up to 64 cores |
| Memory | 7 GB RAM | Up to 512 GB RAM |
| Disk (SSD) | 14 GB free | Up to 32 TB |
| Network egress | ~250 Mbps | Same |
Long-running builds that compile large codebases, run heavy test suites, or process large datasets Should consider self-hosted runners or larger runner types.
Cost Management
Section titled “Cost Management”GitHub Actions bills per minute of runner usage, with multipliers per OS:
| Runner | Multiplier |
|---|---|
| Linux | 1× |
| Windows | 2× |
| macOS | 10× |
Cost optimization strategies:
- Use
pathsfilters to avoid unnecessary workflow runs - Use
concurrencywithcancel-in-progress: trueto avoid redundant runs - Cache dependencies aggressively to reduce build times
- Prefer Linux runners unless Windows/macOS is explicitly required
- Set
timeout-minuteson every job to cap runaway costs - Use
actions/cache@v4instead of re-downloading dependencies on every run - Evaluate whether a matrix strategy is testing combinations that provide diminishing returns
Fork PR Workflows and Secrets
Section titled “Fork PR Workflows and Secrets”Pull requests from forks run in the context of the fork, which means:
- Repository secrets are not available (even with
secrets: inherit) - The
GITHUB_TOKENhas read-only access to the upstream repository writepermissions onGITHUB_TOKENare silently downgraded toread
If a workflow must access secrets on fork PRs, use a separate workflow triggered by pull_request_targetWhich runs in the context of the base repository. However, this introduces a Security risk: an untrusted fork can exfiltrate secrets by modifying the checked-out code. Always Pin the checkout ref explicitly:
on: pull_request_target: branches: [main]
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} # WARNING: running untrusted code with repository secretsThis pattern should only be used when the build step does not execute user-supplied code (e.g., Building a container image from a Dockerfile without running it).
Mutable Checkout Ref on pull_request_target
Section titled “Mutable Checkout Ref on pull_request_target”When using pull_request_targetThe default checkout is the base branch, not the PR head. To check Out the PR head:
- uses: actions/checkout@v4 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }}This is safe only when the subsequent steps do not execute arbitrary code from the PR (e.g., running A static analysis tool on the source code, not running the source code itself).
Summary
Section titled “Summary”This topic covers the core concepts of github actions ci/cd patterns, including underlying theory, practical implementation, and key applications.
Key concepts include:
- Git fundamentals (add, commit, push, pull)
- branching and merging strategies
- resolving merge conflicts
- rebasing and cherry-picking
- Git workflows (GitFlow, trunk-based)
Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.
Worked Examples
Section titled “Worked Examples”Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.
Cross-References
Section titled “Cross-References”- Git Fundamentals — Understanding Git basics is essential before configuring CI/CD workflows that trigger on commits and branches.
- Branching and Merging — Branch protection rules in CI/CD depend on understanding branching strategies and merge workflows.
- Pre-commit Framework — Pre-commit hooks complement CI/CD by catching issues locally before they reach the pipeline.
- Code Review Practices — Pull request workflows and review processes integrate with CI checks in GitHub Actions.