Mental model: an event triggers a workflow (YAML in .github/workflows/); a workflow runs one or more jobs in parallel (or sequenced via needs) on runners; each job runs an ordered list of steps, which are either shell commands or reusable actions.
Event → workflow file match → job scheduling → runner execution → artifacts / status
Every workflow file needs these top-level keys
name: CIshown in the Actions tabon: [push, pull_request]event(s) that trigger the runjobs:map of job_id → job config runs-on: ubuntu-latestrequired per job steps:ordered list, run top to bottomRepository layout conventions
.github/workflows/*.ymlworkflow definitions (must be on default branch for some events)action.yml / action.yamlmetadata file for a custom action.github/CODEOWNERS, issue/PR templatesnot Actions, but same directory familyon: keys, single event, list, or map with filters
push:commits pushed; filter with branches, tags, pathspull_request:PR opened/sync/etc; filter with typesworkflow_dispatch:manual run button; supports typed inputsschedule: cron: '0 3 * * *'UTC cron; min interval ~5 min, may be delayed under loadrelease: types: [published]release lifecycle eventsworkflow_call:makes this workflow reusable by othersrepository_dispatch:custom event fired via REST APIpull_request_target: dangerruns with base-repo secrets against fork code — never checkout + run untrusted PR code hereTyped parameters for manual runs
type: choice / string / boolean / environmentsupported input typesrequired: true, default: 'x'standard input fields${{ github.event.inputs.name }}or the shorter ${{ inputs.name }}Configured per entry under jobs:
runs-on:required — runner label or expressionneeds: [job_a, job_b]run only after listed jobs succeed; enables sequencingif:skip the whole job unless condition is truetimeout-minutes: 15default is 360; always set explicitlycontinue-on-error: truejob/step failure won't fail the runenvironment: productionties job to protection rules & env secretsoutputs:map job_output → ${{ steps.id.outputs.x }}container: node:20run job steps inside a Docker containerservices:sidecar containers (e.g. postgres, redis) for the jobFan a job out across a combination grid
strategy: matrix:define axes, e.g. os: [ubuntu-latest, macos-latest]include: / exclude:add one-off combos or remove specific onesfail-fast: falsedefault true cancels the whole matrix on first failuremax-parallel: 2throttle concurrent matrix jobs${{ matrix.os }}, ${{ matrix.node }}reference axis values anywhere in the jobWhere the job actually executes
ubuntu-latest, windows-latest, macos-latestGitHub-hosted, fresh VM per jobubuntu-24.04-armArm-based hosted runnerubuntu-latest-16-coreslarger hosted runner (paid, org/enterprise)[self-hosted, linux, gpu]label set routes to self-hosted fleetrunner.os, runner.arch, runner.temprunner context, available once job is scheduledPrevent overlapping runs
concurrency: group: ${{ github.ref }}only one run per group at a timecancel-in-progress: trueauto-cancel superseded runs (great for PR pushes)queue: single | maxhow many pending runs may wait in the groupconcurrency can be workflow- or job-leveljob-level = only that job is serializedEach list item under steps:
uses: owner/repo@refrun a published or local actionrun: echo hishell command; multi-line via |with:input parameters passed to the actionid: buildreference this step's outputs laterenv:environment variables for this step onlyif:skip this one step conditionallyshell: bashbash, pwsh, python, sh, cmd, powershellworking-directory: ./appcwd for the run: commandcontinue-on-error: truelet this step fail without failing the jobThe 90%-of-workflows toolkit
actions/checkout@v7clone the repo; needed before most git-aware stepsactions/setup-node@v7, setup-python@v7, setup-java@v6install a language runtime, with built-in caching option. Current majors all run on Node 24 (Node 20 removed from runners Sep 2026).actions/cache@v6restore/save dependency cache by keyactions/upload-artifact@v7 / download-artifact@v7persist files between jobs / for later downloadactions/github-script@v7run JS with an authenticated Octokit client inlineactions/attest-build-provenancegenerate signed SLSA build provenance for your artifacts (needs id-token: write & attestations: write); pin the current major.actions/create-release, softprops/action-gh-releasepublish GitHub Releasesdocker/build-push-action@v6build & push container imagesCommon with: tweaks for actions/checkout
fetch-depth: 0full history (default is depth 1, shallow)ref: ${{ github.event.pull_request.head.sha }}check out a specific ref/SHApersist-credentials: false securityavoid leaving the token usable by later steps/scriptssubmodules: truealso check out submodulesSpeed up installs; best-effort, not guaranteed
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}exact-match key, usually hash of a lockfilerestore-keys:fallback prefixes for a partial cache hitpath:directory/directories to cachesetup-node: cache: 'npm'many setup-* actions wrap cache automaticallyDurable output files (cache ≠ artifact)
upload-artifact: name / pathstore build output, test reports, binariesretention-days: 7default 90; shorten to save storageif-no-files-found: warn|error|ignorebehavior when the path matches nothingdownload-artifact: pattern:pull one or many artifacts into a later jobTalk back to the runner via files/echo
echo "x=1" >> $GITHUB_OUTPUTset a step output (replaces deprecated ::set-output::)echo "VAR=1" >> $GITHUB_ENVexport an env var to subsequent stepsecho "..." >> $GITHUB_STEP_SUMMARYmarkdown shown on the run summary pageecho "::error file=a.js::msg"annotate; also ::warning::, ::notice::echo "::group::name" ... "::endgroup::"collapsible log sectionsecho "::add-mask::$VALUE" securityredact a runtime-computed secret in logsObjects available inside ${{ }}
github.*event payload, sha, ref, actor, repository, tokenenv.*, vars.*workflow/job env vars & configuration variablessecrets.*encrypted secrets; not printed to logs (masked)needs.<job>.outputs.*, needs.<job>.resultread another job's outputs/statusmatrix.*current combination in a matrix jobsteps.<id>.outputs.*, steps.<id>.outcomeread a prior step's outputs/statusjob.status, runner.*, inputs.*job status; runner info; workflow_call/dispatch inputsBuilt-ins usable inside ${{ }}
contains(), startsWith(), endsWith()string/array matchingformat('{0}-{1}', a, b)string templatingjoin(array, sep)array to stringtoJSON(), fromJSON()debug a context / parse a JSON string or matrixhashFiles('**/lock')deterministic hash, common for cache keysif:)Control step/job execution around failures
success()default implicit condition — all previous steps passedfailure()true if any previous step failedalways()runs even if cancelled — risky for checkout-dependent steps!cancelled()preferred over always() to still skip on cancellationcancelled()true only if the run was cancelledConfig vs. sensitive values
secrets.GITHUB_TOKENauto-generated per run; scope set via permissions:secrets.MY_TOKENrepo / environment / org-level secretvars.MY_VARnon-sensitive configuration variable, same scoping tierssecrets: not usable in if: directlycopy to an env var first, then check the env varLeast-privilege scoping
permissions: read-all is not the defaultnew repos default to read-only contents since 2023permissions: contents: writegrant only the scopes a job actually needspermissions: {}explicitly deny everything as a safe baselineid-token: writerequired to mint an OIDC token for cloud authGate deploys behind approvals
jobs.<id>.environment: productionties the job to an environment's rules & secretsrequired reviewersmanual approval before the job proceedswait timermandatory delay before deploymentdeployment branch/tag policyrestrict which refs may deployworkflow_call vs plain workflow_dispatch
on: workflow_call: inputs / secrets / outputsdefines the called workflow's contractjobs.<id>.uses: org/repo/.github/…/x.yml@maininvoke it from a caller workflowwith: / secrets:pass inputs and secrets down to the calleesecrets: inheritpass all caller secrets through (convenient, less explicit)action.yml → runs.using
using: compositewraps existing steps; simplest, runs on the caller's runnerusing: node20JavaScript/TypeScript action; fast, cross-platformusing: dockerpackages a Dockerfile; heavier but full OS control (Linux runners only)Reduce supply-chain & secret-leak risk
uses: owner/action@<full-SHA>pin third-party actions by commit SHA, not a mutable tagpull_request_targetthat job runs with base-repo secrets — classic fork attack vectorid-token: write + OIDCprefer short-lived federated tokens over long-lived cloud secretsrun: can execute arbitrary shellFrequent points of confusion
secrets are unavailable to workflows triggered from forksby design, for pull_request (not _target)timeout-minutes explicitly to fail fast & control costrestore-keys fallbacka pure lockfile-hash key misses on every dependency bumprun: needs |and watch indentation — YAML is whitespace-sensitiveTrade long-lived cloud keys for a short-lived token minted per run
needs sequences jobs; if conditionally skips regardless of orderalways() can hang a cancelled run; !cancelled() is the safer idiom$GITHUB_OUTPUT / $GITHUB_ENV environment files instead@v4 can be moved by the action's maintainer (or an attacker)