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
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.
Environment variables are resolved in this order — more specific wins:
env:env:env:timeout-minutes explicitly — 15 minutes is a reasonable default for most CI jobs; 30–60 for integration tests.
The on: key is where most subtle bugs in Actions originate. Each trigger has different context availability and permission behaviour.
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 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.
| Trigger | Fires when | Common use |
|---|---|---|
release | A GitHub Release is published, created, or edited | Build and publish release artifacts, Docker images |
create | A branch or tag is created | Set up branch-specific environments |
delete | A branch or tag is deleted | Tear down preview environments |
issue_comment | A comment is posted on an issue or PR | Slash command bots (/deploy, /retest) |
deployment | A GitHub Deployment is created (via API) | Deploy to an environment; update deployment status |
workflow_run | Another workflow completes (success/failure/completion) | Run post-CI steps that need full secrets after a fork PR's CI finishes |
Fresh VM per job, torn down after. No persistent state between runs (use caching for that). The runner label determines the image:
| Label | OS | Arch | Free minutes/month | Paid rate |
|---|---|---|---|---|
ubuntu-latest / ubuntu-24.04 | Ubuntu 24.04 | x64 | 2,000 | $0.008/min |
windows-latest / windows-2025 | Windows Server 2025 | x64 | 2,000 (×2 multiplier) | $0.016/min |
macos-latest / macos-15 | macOS 15 (Sequoia) | arm64 | 2,000 (×10 multiplier) | $0.08/min |
macos-13 | macOS 13 (Ventura) | x64 | — | $0.08/min |
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.
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:
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.
| Function | Returns 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 |
Contexts are objects that expose information about the workflow run, the triggering event, the runner, and the current job. Access them with ${{ context.property }}.
Event payload, repo info, actor, ref, SHA, workflow name, run ID. The richest context — most conditional logic starts here.
All environment variables set at workflow, job, or step level. Read with ${{ env.MY_VAR }}.
All secrets accessible to this workflow. Values are masked in logs. Only available via ${{ secrets.NAME }} — cannot be iterated.
Configuration variables (non-secret). Set at org/repo/environment scope. Use for non-sensitive config like registry URLs, feature flag names.
OS, architecture, temp dir, tool cache path. Useful for platform-specific steps: ${{ runner.os == 'Windows' }}.
Current job's status and container info. job.status is useful in cleanup steps that run with always().
Outputs and outcomes of previous steps in the same job. Access with ${{ steps.step-id.outputs.key }} and ${{ steps.step-id.outcome }}.
Outputs and results of jobs listed in needs:. Use to pass data between jobs: ${{ needs.build.outputs.version }}.
Inputs from workflow_dispatch or workflow_call. Available as ${{ inputs.param_name }}.
Expressions are evaluated inside ${{ }} delimiters. They support operators and a set of built-in functions for common workflow logic.
| Function | Use case | Example |
|---|---|---|
contains(str, substr) | Check if a string or array contains a value | contains(github.ref, 'release/') |
startsWith(str, prefix) | Prefix check | startsWith(github.ref, 'refs/tags/v') |
endsWith(str, suffix) | Suffix check | endsWith(matrix.os, '-latest') |
format(str, ...args) | String interpolation with indexed args | format('ghcr.io/{0}/{1}:{2}', github.repository_owner, 'app', github.sha) |
join(array, sep) | Join array elements into a string | join(matrix.os, ', ') |
toJSON(value) | Serialise a value to JSON string | toJSON(github.event) — useful for debugging |
fromJSON(string) | Parse a JSON string to an object/array | fromJSON(steps.detect.outputs.services) for dynamic matrices |
hashFiles(pattern) | Hash matched files — used for cache keys | hashFiles('**/package-lock.json') |
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.
| Secrets | Variables (vars) | |
|---|---|---|
| Purpose | Sensitive values: tokens, passwords, private keys, API secrets | Non-sensitive config: URLs, feature flag names, version numbers |
| Masked in logs? | Yes — value is redacted if it appears in output | No — 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 exists | Yes — full value readable via API |
| Max size | 64 KB | 48 KB |
Both secrets and variables are set at three scopes:
DEPLOY_KEY at repo level for staging and a different DEPLOY_KEY at environment level for production without changing workflow code.
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.
| Rule | What it does | When to use |
|---|---|---|
| Required reviewers | The 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 timer | Delays the job by N minutes (1–43,200) after the trigger | Canary releases — deploy to 1% and wait 30 minutes before proceeding to 100% |
| Deployment branches | Only branches matching a pattern can deploy to this environment | Lock production to only accept deploys from main or release/** |
| Deployment tags | Only tags matching a pattern can deploy | Require a v* tag for production — no deploying from a commit SHA directly |
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.
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.
| Limit | Value |
|---|---|
| Max artifact size per upload | 10 GB (compressed) |
| Total artifact storage for an org | Depends on plan; counted against GitHub Storage quota |
| Default retention | 90 days (configurable down to 1 day in repo settings) |
| Max retention | 90 days (or 400 days on Enterprise) |
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.
The cache key determines when a new cache is created. Good keys are specific enough to avoid stale hits, broad enough to maximise reuse.
| Scenario | Key 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') }} |
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.
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.
Many actions/setup-* actions have a cache: input that handles this automatically — prefer them over manual actions/cache where available:
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.
--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.
If your build toolchain requires a specific environment, run the entire job inside a Docker container instead of installing tools in setup steps:
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.
Also useful: ACTIONS_RUNNER_DEBUG=true enables runner-level diagnostics (infrastructure, network, agent communication).
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.
When you need to poke around inside a failing runner interactively, mxschmitt/action-tmate opens an SSH session inside the runner:
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.
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.
hashFiles() output and compare across runslocalhost not the container name in run stepsGITHUB_TOKEN permission denied → add the specific permission under permissions:; default is read for most scopes