PHASE 5 OF 14

GitHub Actions: Foundations & Architecture

Complete workflow YAML anatomy, every trigger type explained, context objects, expressions, secrets vs variables, environments, artifacts, caching, service containers, and debugging techniques — the reference you reach for when something isn't working

GitHub Actions Workflows Triggers Contexts Caching Secrets Environments
5.1

Workflow YAML Anatomy

Every workflow file lives in .github/workflows/ and is a YAML document. Understanding the full structure — not just the happy path — is essential for writing maintainable, predictable workflows.

# .github/workflows/ci.yml — annotated anatomy name: CI # shown in the Actions tab; defaults to filename if omitted on: # trigger(s) — covered in 5.2 push: branches: [main] pull_request: permissions: # GITHUB_TOKEN scopes for the whole workflow contents: read pull-requests: write concurrency: # cancel in-progress runs on the same branch group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: # workflow-level env vars — available in all jobs/steps NODE_VERSION: '20' REGISTRY: ghcr.io defaults: # default shell and working-dir for all run steps run: shell: bash working-directory: ./app jobs: test: # job ID — used in needs: references name: Unit Tests # display name in the UI runs-on: ubuntu-latest timeout-minutes: 15 # fail the job if it runs longer than this env: # job-level env vars — override workflow-level DATABASE_URL: postgres://localhost/testdb steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 # full history (default is shallow) - name: Setup Node uses: actions/setup-node@v4 with: node-version: ${{ env.NODE_VERSION }} cache: npm - name: Install dependencies run: npm ci - name: Run tests id: tests # id lets later steps reference this step's outputs run: npm test -- --reporter=json > results.json env: # step-level env — most specific, highest precedence CI: true - name: Upload test results if: always() # run even if tests failed uses: actions/upload-artifact@v4 with: name: test-results path: results.json

env precedence

Environment variables are resolved in this order — more specific wins:

  1. Step-level env:
  2. Job-level env:
  3. Workflow-level env:
TIMEOUT DEFAULT
The default job timeout is 6 hours. A hung test or a process waiting for input will consume runner minutes for 6 hours before GitHub kills it. Always set timeout-minutes explicitly — 15 minutes is a reasonable default for most CI jobs; 30–60 for integration tests.
5.2

Trigger Deep Dive

The on: key is where most subtle bugs in Actions originate. Each trigger has different context availability and permission behaviour.

push and pull_request

on: push: branches: [main, 'release/**'] branches-ignore: ['dependabot/**'] # mutually exclusive with branches: tags: ['v*'] paths: ['src/**', 'package.json'] paths-ignore: ['docs/**', '**.md'] # mutually exclusive with paths: pull_request: types: [opened, synchronize, reopened, ready_for_review] # default types if omitted: [opened, synchronize, reopened] branches: [main] # base branch the PR targets (not the feature branch)
pull_request PERMISSION RESTRICTION
Workflows triggered by pull_request from a fork run with read-only GITHUB_TOKEN and cannot access repo secrets. This is a security boundary — a forked PR can't exfiltrate your secrets. Use pull_request_target if you need write access for fork PRs, but be aware it runs in the context of the base repo and is a potential attack surface if you checkout untrusted code in the same job.

schedule

on: schedule: - cron: '0 2 * * 1-5' # 2am UTC, Mon–Fri - cron: '0 9 * * 1' # 9am UTC every Monday (stale PR report)

Schedule triggers only run on the default branch. They don't run on feature branches or tags. Minimum interval is 5 minutes (GitHub enforces this). Schedules may run up to 15 minutes late during high-load periods.

workflow_dispatch — manual trigger with inputs

on: workflow_dispatch: inputs: environment: description: 'Target environment' type: choice options: [staging, production] required: true default: staging dry_run: description: 'Skip actual deployment' type: boolean default: true version_tag: description: 'Git tag to deploy (e.g. v2.4.1)' type: string required: false # Access inputs in steps: # ${{ inputs.environment }} ${{ inputs.dry_run }}

workflow_call — reusable workflow trigger

# In the reusable workflow (called): on: workflow_call: inputs: environment: type: string required: true secrets: DEPLOY_KEY: required: true outputs: deployed_version: description: 'The version that was deployed' value: ${{ jobs.deploy.outputs.version }} # In the caller workflow: jobs: deploy: uses: org/platform-workflows/.github/workflows/deploy.yml@main with: environment: production secrets: DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }} # OR: secrets: inherit — passes all caller secrets to the reusable workflow

repository_dispatch — external webhook trigger

on: repository_dispatch: types: [deploy-staging, run-integration-tests] # Trigger from outside GitHub (e.g. your CD system, a Slack bot): curl -X POST \ -H "Authorization: Bearer $GH_TOKEN" \ -H "Accept: application/vnd.github+json" \ https://api.github.com/repos/org/repo/dispatches \ -d '{"event_type":"deploy-staging","client_payload":{"version":"v2.4.1"}}' # Access payload in the workflow: # ${{ github.event.client_payload.version }}

Other useful triggers

TriggerFires whenCommon use
releaseA GitHub Release is published, created, or editedBuild and publish release artifacts, Docker images
createA branch or tag is createdSet up branch-specific environments
deleteA branch or tag is deletedTear down preview environments
issue_commentA comment is posted on an issue or PRSlash command bots (/deploy, /retest)
deploymentA GitHub Deployment is created (via API)Deploy to an environment; update deployment status
workflow_runAnother workflow completes (success/failure/completion)Run post-CI steps that need full secrets after a fork PR's CI finishes
5.3

Runner Types

GitHub-hosted runners

Fresh VM per job, torn down after. No persistent state between runs (use caching for that). The runner label determines the image:

LabelOSArchFree minutes/monthPaid rate
ubuntu-latest / ubuntu-24.04Ubuntu 24.04x642,000$0.008/min
windows-latest / windows-2025Windows Server 2025x642,000 (×2 multiplier)$0.016/min
macos-latest / macos-15macOS 15 (Sequoia)arm642,000 (×10 multiplier)$0.08/min
macos-13macOS 13 (Ventura)x64$0.08/min
ALWAYS PIN THE VERSION
ubuntu-latest moves to a new Ubuntu version without notice, potentially breaking your build. Pin to ubuntu-24.04 for reproducibility. Update deliberately after testing, not accidentally during an unrelated push.

Larger GitHub-hosted runners (Teams / Enterprise)

Standard runners have 2 vCPU / 7 GB RAM. For compute-intensive builds — large test suites, Docker builds, ML model compilation — use larger runners defined in your org settings:

# After creating a larger runner in org Settings → Actions → Runners runs-on: ubuntu-latest-8-core # name you gave it when creating # GPU runners also available for ML workflows: runs-on: linux-gpu-nvidia-t4-4-core

Self-hosted runners

Runners you manage on your own infrastructure. Persistent state between runs (unless you configure ephemeral mode). Required for: private network access, specialised hardware, compliance requirements that prohibit cloud runners.

runs-on: [self-hosted, linux, x64, production-network] # Labels are arbitrary — you assign them when registering the runner # Use multiple labels to select runners with specific capabilities
SELF-HOSTED SECURITY WARNING
Never use self-hosted runners on public repositories. A malicious PR could modify workflow files to execute arbitrary code on your runner, accessing your internal network, secrets stored on the host, and any credentials the runner process has access to. Self-hosted runners should only be used on private repos with strict branch protection.
5.4

Job Dependencies: needs:, Fan-Out / Fan-In, Conditional Jobs

needs: — sequential and parallel jobs

jobs: build: # runs first runs-on: ubuntu-latest outputs: image_tag: ${{ steps.tag.outputs.tag }} steps: - id: tag run: echo "tag=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT test-unit: # runs in parallel with test-integration needs: build runs-on: ubuntu-latest test-integration: # runs in parallel with test-unit needs: build runs-on: ubuntu-latest deploy-staging: # fan-in: waits for BOTH tests to pass needs: [test-unit, test-integration] runs-on: ubuntu-latest steps: - run: echo "Deploying ${{ needs.build.outputs.image_tag }}"

Conditional jobs with if:

jobs: deploy-prod: needs: [test-unit, test-integration, deploy-staging] if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest notify-failure: needs: [test-unit, test-integration] if: failure() # runs only if any needed job failed runs-on: ubuntu-latest steps: - run: echo "Sending failure alert to Slack..." cleanup: needs: [deploy-prod] if: always() # always() runs regardless of upstream success/failure runs-on: ubuntu-latest

Status check functions in if: expressions

FunctionReturns true when
success()All previous steps/jobs succeeded (the implicit default)
failure()Any previous step/job in the dependency chain failed
cancelled()The workflow was cancelled
always()Regardless of any previous result — even if cancelled
5.5

Context Objects

Contexts are objects that expose information about the workflow run, the triggering event, the runner, and the current job. Access them with ${{ context.property }}.

github

Event payload, repo info, actor, ref, SHA, workflow name, run ID. The richest context — most conditional logic starts here.

env

All environment variables set at workflow, job, or step level. Read with ${{ env.MY_VAR }}.

secrets

All secrets accessible to this workflow. Values are masked in logs. Only available via ${{ secrets.NAME }} — cannot be iterated.

vars

Configuration variables (non-secret). Set at org/repo/environment scope. Use for non-sensitive config like registry URLs, feature flag names.

runner

OS, architecture, temp dir, tool cache path. Useful for platform-specific steps: ${{ runner.os == 'Windows' }}.

job

Current job's status and container info. job.status is useful in cleanup steps that run with always().

steps

Outputs and outcomes of previous steps in the same job. Access with ${{ steps.step-id.outputs.key }} and ${{ steps.step-id.outcome }}.

needs

Outputs and results of jobs listed in needs:. Use to pass data between jobs: ${{ needs.build.outputs.version }}.

inputs

Inputs from workflow_dispatch or workflow_call. Available as ${{ inputs.param_name }}.

Commonly used github context properties

${{ github.actor }} # username who triggered the workflow ${{ github.repository }} # "owner/repo" ${{ github.ref }} # "refs/heads/main" or "refs/tags/v1.0.0" ${{ github.ref_name }} # "main" or "v1.0.0" (short name) ${{ github.sha }} # full 40-char commit SHA ${{ github.event_name }} # "push", "pull_request", "workflow_dispatch", etc. ${{ github.run_id }} # unique ID for this workflow run ${{ github.run_number }} # sequential run count for this workflow ${{ github.head_ref }} # PR source branch (only on pull_request events) ${{ github.base_ref }} # PR target branch (only on pull_request events) ${{ github.server_url }} # "https://github.com" ${{ github.api_url }} # "https://api.github.com"
5.6

Expressions: Operators & Functions

Expressions are evaluated inside ${{ }} delimiters. They support operators and a set of built-in functions for common workflow logic.

Operators

# Comparison github.ref == 'refs/heads/main' github.event_name != 'schedule' runner.os == 'Linux' # Logical github.ref == 'refs/heads/main' && github.event_name == 'push' github.event_name == 'push' || github.event_name == 'workflow_dispatch' !cancelled() # Ternary-style (no ternary operator — use this pattern): ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }}

Built-in functions

FunctionUse caseExample
contains(str, substr)Check if a string or array contains a valuecontains(github.ref, 'release/')
startsWith(str, prefix)Prefix checkstartsWith(github.ref, 'refs/tags/v')
endsWith(str, suffix)Suffix checkendsWith(matrix.os, '-latest')
format(str, ...args)String interpolation with indexed argsformat('ghcr.io/{0}/{1}:{2}', github.repository_owner, 'app', github.sha)
join(array, sep)Join array elements into a stringjoin(matrix.os, ', ')
toJSON(value)Serialise a value to JSON stringtoJSON(github.event) — useful for debugging
fromJSON(string)Parse a JSON string to an object/arrayfromJSON(steps.detect.outputs.services) for dynamic matrices
hashFiles(pattern)Hash matched files — used for cache keyshashFiles('**/package-lock.json')
DEBUGGING EXPRESSIONS
Add a step with run: echo '${{ toJSON(github) }}' to dump the full github context as JSON. Do the same with toJSON(needs), toJSON(steps), or any other context to understand what's actually available at that point in the workflow.
5.7

Secrets vs Variables: Scope & Inheritance

SecretsVariables (vars)
PurposeSensitive values: tokens, passwords, private keys, API secretsNon-sensitive config: URLs, feature flag names, version numbers
Masked in logs?Yes — value is redacted if it appears in outputNo — visible in logs
Access in workflow${{ secrets.NAME }}${{ vars.NAME }}
Readable via API?No — values are write-only via API; you can only check if a name existsYes — full value readable via API
Max size64 KB48 KB

Scope and inheritance

Both secrets and variables are set at three scopes:

Organisation Available to all repos in the org (subject to the org's "repository access" policy — All/Private/Selected). Set in Org Settings → Secrets/Variables.
Repository Available to all workflows in the repo. Set in Repo Settings → Secrets and variables → Actions.
Environment Available only when a job targets a named environment (see 5.8). Environment-scoped secrets override org/repo secrets of the same name.
NAMING CONFLICTS
If an environment secret has the same name as a repo secret, the environment secret wins when the job runs in that environment. This is intentional — it lets you use DEPLOY_KEY at repo level for staging and a different DEPLOY_KEY at environment level for production without changing workflow code.

Setting secrets via CLI

# Set a repo secret $ gh secret set DOCKER_PASSWORD --body "$DOCKER_PASSWORD" # Set from a file (e.g. a private key) $ gh secret set SSH_PRIVATE_KEY < ~/.ssh/deploy_key # Set an environment secret $ gh secret set DEPLOY_KEY --env production --body "$PROD_KEY" # List secrets (names only — values are not shown) $ gh secret list $ gh secret list --env production
5.8

Environments: Protection Rules, Required Reviewers & Wait Timers

Environments are named deployment targets (e.g. staging, production) that add protection gates to jobs. A job that targets an environment must pass all of the environment's protection rules before it can start.

jobs: deploy: runs-on: ubuntu-latest environment: name: production url: ${{ steps.deploy.outputs.url }} # shown on the deployment page steps: - id: deploy run: ./deploy.sh

Protection rules

RuleWhat it doesWhen to use
Required reviewersThe workflow pauses and waits for one of the named reviewers to approve before the job starts. Up to 6 reviewers.Production deploys — a human must confirm before deployment proceeds
Wait timerDelays the job by N minutes (1–43,200) after the triggerCanary releases — deploy to 1% and wait 30 minutes before proceeding to 100%
Deployment branchesOnly branches matching a pattern can deploy to this environmentLock production to only accept deploys from main or release/**
Deployment tagsOnly tags matching a pattern can deployRequire a v* tag for production — no deploying from a commit SHA directly
ENVIRONMENT URL
Setting the url: on the environment step creates a "View deployment" link on the PR and on the deployment history page. This lets reviewers click straight from a PR to the staging preview. Use step outputs to construct the URL dynamically: https://staging-${{ github.event.number }}.app.example.com.
5.9

Artifacts: Upload, Download & Retention

Artifacts pass files between jobs in a workflow, or persist build outputs for later download. They live on GitHub's servers for the retention period, not in the Git repo.

# Job 1: build and upload the artifact jobs: build: runs-on: ubuntu-latest steps: - run: npm run build - uses: actions/upload-artifact@v4 with: name: dist-${{ github.sha }} path: dist/ retention-days: 7 # default is 90 days compression-level: 6 # 0=none, 6=balanced, 9=max if-no-files-found: error # error | warn | ignore # Job 2: download and deploy deploy: needs: build runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v4 with: name: dist-${{ github.sha }} path: ./dist # where to place files on the runner

Downloading all artifacts from a run

# Download all artifacts (omit name: to get everything) - uses: actions/download-artifact@v4 with: path: all-artifacts/ # each artifact in its own subdirectory merge-multiple: false
LimitValue
Max artifact size per upload10 GB (compressed)
Total artifact storage for an orgDepends on plan; counted against GitHub Storage quota
Default retention90 days (configurable down to 1 day in repo settings)
Max retention90 days (or 400 days on Enterprise)
5.10

Caching: Key Strategies & Hit Rate Tuning

Caching persists directories between workflow runs, primarily for dependency installation. A good cache strategy cuts minutes off every CI run. A bad one causes subtle bugs from stale state.

Basic cache pattern

- uses: actions/cache@v4 id: cache with: path: | ~/.npm node_modules key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- # fallback: use any node cache for this OS ${{ runner.os }}- # broader fallback - run: npm ci if: steps.cache.outputs.cache-hit != 'true' # skip install on cache hit

Cache key strategy

The cache key determines when a new cache is created. Good keys are specific enough to avoid stale hits, broad enough to maximise reuse.

ScenarioKey pattern
npm / yarn${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
Maven${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
Gradle${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
pip${{ runner.os }}-pip-${{ hashFiles('**/requirements*.txt') }}
Go modules${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
Docker layer cache${{ runner.os }}-buildx-${{ hashFiles('Dockerfile', '.dockerignore') }}

restore-keys fallback chain

When the exact key misses, GitHub tries each restore-keys prefix in order, using the most recently created matching cache. This means even after a package-lock.json change, you restore the previous cache (containing most of the same packages) and only install the delta — much faster than a cold install.

CACHE SCOPE
Caches are scoped to a branch. A branch can read caches from its base branch (usually main) but not from unrelated branches. So main's cache is the "warm" fallback for all feature branches — ensure your main branch CI populates the cache on every push.

Setup actions with built-in caching

Many actions/setup-* actions have a cache: input that handles this automatically — prefer them over manual actions/cache where available:

- uses: actions/setup-node@v4 with: node-version: '20' cache: npm # handles cache key + restore automatically - uses: actions/setup-python@v5 with: python-version: '3.12' cache: pip - uses: actions/setup-java@v4 with: java-version: '21' distribution: temurin cache: maven # or 'gradle'
5.11

Service Containers: Databases & Redis in CI

Service containers spin up Docker containers alongside your job — typically a database, message broker, or cache — that your tests can connect to. They start before your job steps and are torn down after.

jobs: integration-tests: 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 --health-interval 10s --health-timeout 5s --health-retries 5 redis: image: redis:7-alpine ports: - 6379:6379 options: >- --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 3 steps: - uses: actions/checkout@v4 - run: npm test env: DATABASE_URL: postgres://testuser:testpass@localhost:5432/testdb REDIS_URL: redis://localhost:6379
HEALTH CHECKS ARE MANDATORY
Without --health-cmd, your job steps start immediately and may run before the database is ready to accept connections — causing flaky tests. The options: health check makes GitHub wait until the container reports healthy before starting your steps.

Container jobs (running steps inside a container)

If your build toolchain requires a specific environment, run the entire job inside a Docker container instead of installing tools in setup steps:

jobs: build: runs-on: ubuntu-latest container: image: node:20-alpine credentials: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@v4 - run: node --version && npm ci && npm test
5.12

Debugging: Step Debug Logs, tmate & Re-run with Debug

Enable step debug logging

Set a secret (not a variable — secrets are masked to prevent the flag from leaking) named ACTIONS_STEP_DEBUG to true. This enables verbose output for every step, including the internal operations of actions.

$ gh secret set ACTIONS_STEP_DEBUG --body "true" # Remember to delete or set to false after debugging $ gh secret delete ACTIONS_STEP_DEBUG

Also useful: ACTIONS_RUNNER_DEBUG=true enables runner-level diagnostics (infrastructure, network, agent communication).

Re-run with debug logging (UI)

In the GitHub UI, when a run fails, click the dropdown on "Re-run jobs" and choose "Re-run with debug logging". This enables step debug for that run only — no secret required. Available for all runs on the default branch and on PRs.

Dump context for inspection

- name: Debug — dump all contexts if: runner.debug == '1' # only runs when debug logging is enabled run: | echo "=== github ===" echo '${{ toJSON(github) }}' echo "=== env ===" echo '${{ toJSON(env) }}' echo "=== runner ===" echo '${{ toJSON(runner) }}'

Interactive debugging with tmate

When you need to poke around inside a failing runner interactively, mxschmitt/action-tmate opens an SSH session inside the runner:

- name: Debug — SSH into runner uses: mxschmitt/action-tmate@v3 if: failure() # only open session when job fails with: limit-access-to-actor: true # only the PR author can connect timeout-minutes: 15 # auto-terminate after 15 min

The workflow logs print an SSH command. Connect with your terminal, inspect files, run commands, and check environment. The session keeps the runner alive until the timeout.

SECURITY — TMATE ON PUBLIC REPOS
Never use tmate on public repositories without limit-access-to-actor: true. Without it, anyone who can see the logs can connect to your runner and access any secrets loaded into the environment.

Common debugging checklist

  • Step fails with "command not found" → check the runner OS; a command available on macOS may not be on Ubuntu
  • Secret is blank in a step → check the secret name is exactly right (case-sensitive); check environment scope if using environments
  • Workflow doesn't trigger → verify the branch/path filter; check that the event type matches what you configured
  • Cache always misses → add a debug step to print hashFiles() output and compare across runs
  • Service container connection refused → verify health check passes; check the port mapping; use localhost not the container name in run steps
  • GITHUB_TOKEN permission denied → add the specific permission under permissions:; default is read for most scopes

Up Next — Phase 6: GitHub Actions Advanced Patterns

Reusable workflows, composite actions, custom JavaScript and Docker actions, dynamic matrices, concurrency groups, OIDC keyless auth to AWS/GCP/Azure, and self-hosted runner scaling with ARC.

Continue to Phase 6 → Back to Hub