GitHub Actions is an automation system in which events trigger workflows, workflows orchestrate jobs, and jobs execute steps sequentially on runners. This article develops a mental model of CI execution, covering YAML primitives, dependencies, matrices, concurrency, and the boundaries between caches and artifacts. For a complete CI quality-gate configuration, see Configuring Actions CI; for credential and untrusted-input security, see Actions Security and Troubleshooting.
Actions is more than simply “running a shell script on GitHub.” A run is determined jointly by its event, configuration version, permissions, job topology, and runner environment: the event determines when it starts, the workflow file describes what to do, jobs define isolation and parallelism boundaries, steps define individual operations, and the runner supplies the operating system and tools.
1. Workflow Files and Events#
1.1 File Location and Top-Level Structure
Workflows must be placed under .github/workflows/*.yml or .github/workflows/*.yaml at the repository root. The filename is just a human-readable identifier; GitHub displays the workflow on the Actions page using its name.
Remember the top-level keys by their responsibilities: name is the UI name, on defines events and filters, permissions defines token scopes, env/defaults sets execution defaults, concurrency controls concurrency, and jobs defines the execution topology. They address display, triggering, permissions, environment, queuing, and execution, respectively.
1.2 push, pull_request, and workflow_dispatch
push: runs when a branch or tag receives new commits. It is suitable for checking commits that have reached a remote branch, such as continuous checks on the default branch. Withoutbranches,branches-ignore,tags, ortags-ignore, both branch and tag pushes may trigger it; explicitly filter the intended scope.pull_request: runs when specified pull request activity occurs, making it suitable for validating changes before merging. Here,branchesmatches the PR's target branch, not the contributor's head branch. Default activity types include common actions such as opening, updating, and reopening a PR; explicitly settypesif only particular changes should trigger a run.workflow_dispatch: runs a workflow manually. You can declareinputsso the operator can choose a version, target, or toggle. It addresses the need to “run this once now”; it is not a scheduled task and does not automatically replacepush/pull_request. To select it manually in the web interface, the workflow file must already exist on the default branch.
The most common CI starting point listens for both push and pull_request: the former validates commits on a branch, and the latter provides pre-merge checks for a PR. They may produce two runs for the same commit. This follows from the event design; GitHub is not executing the same step twice.
1.3 workflow_call: The Contract for Reusable Workflows
workflow_call does not add “another way to run manually”; it declares a workflow as a component that other workflows can call. The called file typically defines only inputs, outputs, and necessary secrets; the caller passes parameters through job-level uses. It suits CI orchestration shared across multiple repositories or entry points, but adds the cost of maintaining a calling contract and versions.
A minimal declaration for the called workflow looks like this:
on:
workflow_call:
inputs:
command:
required: true
type: string
jobs:
check:
runs-on: ubuntu-latest
steps:
- name: Display command input without executing it
env:
COMMAND_LABEL: ${{ inputs.command }}
run: printf '%s\n' "$COMMAND_LABEL"The caller treats it as a job and supplies the actual triggering events:
jobs:
reusable:
uses: ./.github/workflows/reusable-checks.yml
with:
command: git diff --checkHere, command is only data to display; it is not executed. Do not insert caller inputs directly into run or pass them to eval; use fixed commands or a reviewed allowlist when selecting a check command. A called workflow that declares only workflow_call does not run automatically on ordinary pushes. Input types must be declared in the contract, and outputs can be exposed to the caller through job outputs. More complex reuse strategies are covered in the CI configuration article.
2. Jobs, Runners, Steps, Actions, and Shells#
2.1 The Four-Layer Execution Model
Think of a CI run as the following execution tree:
Event
└── Workflow(一个 YAML 文件的一次运行)
├── Job A → Runner A → Step 1 → Step 2
└── Job B → Runner B → Step 1 → Step 2| Primitive | Mental model | Key fields |
|---|---|---|
| Workflow | A set of automation rules defined by a YAML file, and the current run of those rules | name、on、jobs |
| Job | An isolated execution unit; different jobs run in parallel by default | runs-on、needs、strategy、steps |
| Runner | A virtual machine or self-hosted execution environment that hosts one job | runs-on、runner context |
| Step | The smallest orchestration unit, executed sequentially within a job | run、uses、with、env、if |
| Action | Packaged, reusable logic that a step can call | uses: owner/repo@ref |
| Shell | The command interpreter that executes the text in run |
defaults.run.shell、steps[*].shell |
Jobs can run in parallel by default, and each usually receives its own runner and working directory. File changes in one job do not automatically appear in another; transfer them using artifacts, external storage, or another checkout/build. Steps within a job run in their declared order. If a step fails, later steps are skipped by default unless conditional expressions or continue-on-error change that behavior.
2.2 The Difference Between run and uses
runpasses single-line or multiline commands to the runner's shell. It suits a project's own checks, such asnix flake check,git diff --check, ornpm test.usescalls an action. An action can be a JavaScript action, Docker action, or composite action; it accepts inputs throughwithand exposes results through outputs.- Step-level
usesdiffers from job-leveluses: the former calls an action, while the latter calls a reusableworkflow_callworkflow.
An action reference takes the form owner/repository@ref. Pinning it to a full commit SHA makes CI easier to audit:
steps:
- name: Check out source
uses: actions/checkout@<full-commit-sha>Here, <full-commit-sha> is an intentional placeholder, not a version you can copy directly. In practice, obtain the actual full SHA from a trusted upstream source and the project's update history, and add a readable version comment on the same line. Other action examples in this article use the same placeholder and do not print any credentials. See 06 for third-party action trust, permissions, and untrusted-input boundaries; the full security discussion is outside this article's scope.
2.3 Runners and Working Directories
runs-on selects a runner label, such as ubuntu-latest. latest is a moving label managed by GitHub, and the image's preinstalled tools change as the platform is updated. It helps establish CI quickly but is not a fully frozen execution environment. For stronger reproducibility, choose a pinned image or container appropriate to the project's actual support scope, and record tool versions in the project configuration.
The runner prepares $GITHUB_WORKSPACE for the job, and the checkout action normally places the repository there. Steps in a job share the working directory, so a file generated by one step is usually available to the next. However, their shell processes are separate: export TEMP=value does not automatically become an environment variable in the next step. To pass environment values between steps, use:
echo "TEMP=value" >> "$GITHUB_ENV"To pass small, structured results between steps, use:
echo "version=1.2.3" >> "$GITHUB_OUTPUT"3. Contexts, Expressions, Environment Variables, and Defaults#
3.1 Two Interpolation Languages
The two common kinds of “variables” in Actions YAML belong to different phases:
env:
MODE: ci
jobs:
check:
runs-on: ubuntu-latest
steps:
- run: echo "event=${{ github.event_name }} mode=$MODE"${{ ... }}is a GitHub Actions expression that reads a context or calls a function during workflow orchestration.$MODEis an environment variable expanded by the runner's shell when it executesrun.
Do not interchange these syntaxes: github.event_name cannot simply be written as a shell variable, and $MODE is not a GitHub expression. An if condition can often omit the outer ${{ }}, but retaining it makes complex conditions clearer.
3.2 Common Contexts
| Context | Typical contents | Usage example |
|---|---|---|
github |
Repository, branch, event name, commit, and run information | ${{ github.event_name }} |
inputs |
Inputs to workflow_dispatch or workflow_call |
${{ inputs.command }} |
vars |
Repository-, organization-, or environment-level configuration variables | ${{ vars.RUNTIME }} |
env |
Environment variables currently visible in the YAML | ${{ env.MODE }} |
matrix |
Values for the current matrix combination | ${{ matrix.os }} |
needs |
Outputs and results of completed dependency jobs | ${{ needs.build.outputs.version }} |
steps |
Outputs and conclusions of steps with an id in the current job |
${{ steps.meta.outputs.version }} |
runner |
Operating system, temporary directory, and runner information | ${{ runner.os }} |
Context availability depends on the key and execution phase; not every context exists everywhere. In particular, matrix is meaningful only in a job with a matrix, and needs contains only explicitly declared dependency jobs. Use github.event to read the event payload, and do not assume all events have the same fields.
3.3 env, defaults, and GITHUB_*
env can appear at the workflow, job, or step level, with inner levels being more specific. A narrower scope normally overrides variables with the same name. defaults.run can standardize the shell and working directory:
defaults:
run:
shell: bash
working-directory: .GitHub also provides many default environment variables, including GITHUB_WORKSPACE, GITHUB_SHA, GITHUB_REF, and GITHUB_RUN_ID. They describe the current run and should not be hard-coded to a particular branch or commit. Consult the official reference for the exact availability of default variables and contexts; this article relies only on the most stable event-name, branch, and output mechanisms.
permissions and env solve different problems: the former controls the GitHub API permissions of GITHUB_TOKEN, while the latter only sets values visible to processes. Once a permissions mapping is explicitly declared at the workflow or job level, unlisted permission scopes become none. For example:
permissions:
contents: readThis requests only read access for contents at that level; no permissions are granted for other, unlisted scopes. Details of tokens, fork PRs, secrets, and third-party action risks are left to 06.
4. Dependencies, Outputs, Matrices, and Concurrency#
4.1 Expressing Job Dependencies with needs
Without needs, jobs have no data or ordering dependencies, and the platform can run them in parallel. When needs is declared, the current job starts only after its dependency jobs finish successfully. If a dependency fails or is skipped, downstream jobs are also skipped by default.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: ./lint.sh
test:
needs: lint
runs-on: ubuntu-latest
steps:
- run: ./test.shneeds expresses a topology constraint; it does not automatically transfer files or environments. Use job outputs to pass small strings:
jobs:
meta:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.value }}
steps:
- id: version
run: echo "value=1.2.3" >> "$GITHUB_OUTPUT"
show:
needs: meta
runs-on: ubuntu-latest
steps:
- run: echo "version=${{ needs.meta.outputs.version }}"Outputs suit short values or statuses, not large files. Consider artifacts for large files and test reports, and caches for dependency downloads.
4.2 Matrix: Expanding a Job into Multiple Combinations
strategy.matrix declares a set of variables for a job; each combination becomes an independent job. For two dimensions, the number of combinations is their Cartesian product, so cost and the potential for failure grow with the number of combinations:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
runtime: ["3.11", "3.12"]
steps:
- run: echo "${{ matrix.os }} / ${{ matrix.runtime }}"The example above expands into 4 jobs. include adds special combinations, and exclude removes unsupported ones. fail-fast: false lets other combinations continue producing results, which suits CI that needs a complete compatibility report. Small projects should not add a matrix without justification merely to “look comprehensive.” The theme CI teaching example later uses a single flake job and must not be described as matrix CI.
4.3 Concurrency: Canceling Outdated Runs
concurrency can be set at the workflow or job level, using a string or expression to establish a concurrency group. A common CI strategy cancels older, in-progress runs when a new commit arrives on the same branch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueThis reduces duplicate builds but is appropriate only for checks whose older results genuinely no longer matter. Do not blindly cancel releases, migrations, or tasks that must run to completion. Include distinguishing fields such as github.workflow in the concurrency group to prevent different workflows from canceling each other because their groups share a name.
5. Caches and Artifacts: Two Opposite Boundaries#
5.1 Caches Accelerate Dependencies
A cache stores reproducible dependencies or downloaded tools to shorten subsequent runs. Typical inputs are a package manager's cache directory, the lockfile hash, and the runner's operating system:
- name: Cache dependencies
uses: actions/cache@<full-commit-sha>
with:
path: ~/.cache/example
key: ${{ runner.os }}-deps-${{ hashFiles('**/lockfile') }}
restore-keys: |
${{ runner.os }}-deps-A cache hit must not be a prerequisite for correctness: when the cache is missing, the job should be able to download everything again and pass. A changed key creates a new cache, and restore-keys can restore an older, broader match when no exact match exists. Do not treat caches as reliable cross-job file transfer, or store credentials, dynamic build results, or artifacts that require auditing in them.
5.2 Artifacts Are Run Outputs
An artifact stores files generated by a particular workflow run, such as test reports, coverage data, package directories, or debug logs. Later jobs can download it, and it can also be retrieved from the Actions page after the run ends. Its basic form is:
- name: Upload report
uses: actions/upload-artifact@<full-commit-sha>
with:
name: test-report
path: reports/Another job can use actions/download-artifact to download artifacts from the same run. An artifact means “save and share the outputs produced by this run,” not “make the next run faster.”
In one sentence: caches accelerate dependencies that can be lost and rebuilt; artifacts preserve this run's outputs or transfer them between jobs. The nix.yml teaching example later uses neither.
6. A Complete Minimal CI YAML File#
The following example depends only on Git itself and illustrates the execution primitives. On a push to the default branch, a PR targeting that branch, or a manual run, it checks out the code and checks committed patches for whitespace errors. Replace <full-commit-sha> with a real full SHA before using it as an actual workflow.
name: Minimal CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
name: Repository checks
runs-on: ubuntu-latest
defaults:
run:
shell: bash
steps:
- name: Check out source
uses: actions/checkout@<full-commit-sha>
with:
fetch-depth: 2
persist-credentials: false
- name: Check committed patch whitespace
run: |
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
git diff --check HEAD^ HEAD
else
git diff-tree --root -r --check HEAD
fiRead it section by section: name/on define the display name and three entry points; permissions grants only the contents: read needed for checkout, with other unlisted scopes set to none; concurrency cancels outdated runs in the same group; and the sole check job uses Bash on an Ubuntu runner to check out the code and check committed patches for whitespace errors. An ordinary git diff --check usually has nothing to check in a clean checkout. Two levels of history are fetched here: a PR's default merge commit can be compared with its first parent, while push/manual entry points check only the selected commit against its parent, not the full range of a multi-commit push. To check an entire push in a production project, fetch sufficient history using the event's before/after SHAs and handle the all-zero before value for a new branch. Real projects can append formatting, evaluation, build, and test commands, but this example has no needs, outputs, matrix, cache, or artifact.
This example intentionally omits strategy.matrix, actions/cache, upload-artifact, and language runtime installation. Stabilize the minimal check first, then add parallelism, caching, or artifact retention in response to actual bottlenecks.
7. Event-Filtering Pitfalls#
7.1 Branch and Path Filter Semantics
push.branchesmatches the branch being pushed;pull_request.branchesmatches the PR's target branch.- The same event cannot use both
branchesandbranches-ignore. To include and exclude branches together, specifybrancheswith ordered!patterns. - The same event cannot use both
pathsandpaths-ignore. If branch and path filters are both configured, both conditions must match for a run to start. - Positive and negative
pathspatterns are processed in written order. A later negative pattern can exclude a previously matched path, and a later positive pattern can include it again. - Path filters do not apply to tag pushes. If only
tagsortags-ignoreis defined, the undefined branch side does not trigger, and vice versa.
Overly narrow filters can give a false green result when code changes go unchecked; overly broad filters waste runner time. More seriously, checks associated with a workflow skipped by branch, path, or commit-message filtering may remain Pending, preventing merging when those checks are required. Before setting filters, list which changes must trigger a run, then verify the check status for each category.
7.2 An Event Is Not a Code Version
The same repository state has different contexts under different events: push represents commits on a branch, and pull_request represents a PR update. Manual runs require the workflow to exist on the default branch, but an individual run can select an allowed branch or tag ref. Do not infer the target branch from github.ref alone; when needed, inspect github.event_name, github.ref, and the PR fields in the event payload together.
The risks of fork PRs, tokens, secrets, and pull_request_target concern trust boundaries and are covered together in Actions Security and Troubleshooting. This article retains one principle: event filtering determines “when to run”; it does not automatically determine “who supplied the code to execute.”
8. Example: nix.yml in a Theme Project#
Suppose a theme project places its workflow in .github/workflows/nix.yml. The teaching example uses the following structure; it does not represent the current configuration of any personal repository:
| Item | Example configuration |
|---|---|
| Triggering events | push and pull_request, with no additional branch/path filters |
| Job | A single job named flake |
| Runner | ubuntu-latest |
| Token permissions | Workflow-level contents: read |
| Actions | Both checkout and install-nix are pinned to full commit SHAs |
| Checks performed | nix flake check --all-systems --no-build, builds of six theme variants, and output contract checks |
| Matrix/Cache/Artifact | Not configured in this example |
A “single flake job” does not mean just one command: the same job can evaluate, build the variants, and finally check the output contract in sequence. The absence of a matrix is a factual boundary; the six variants are arguments to one nix build command, not six matrix jobs. Having no cache or artifact also does not mean Actions lacks support for them; it means only that dependency acceleration and run-output retention are not part of this example workflow.
9. Official Resources and the Next Article#
- Workflow syntax for GitHub Actions
- Events that trigger workflows
- Running variations of jobs with a matrix
- Dependency caching reference
- Store and share data with workflow artifacts
- Control the concurrency of workflows and jobs
- Reusing workflows
The next step is to organize local checks into stable quality gates, decide which workflow checks should become required checks, and design filtering strategies for duplicate push/PR runs, failure retries, and the scope of changes: Configuring Actions CI.
References & further reading
- Workflow syntax for GitHub Actions ↗
- Events that trigger workflows ↗
- Running variations of jobs with a matrix ↗
- Dependency caching reference ↗
- Store and share data with workflow artifacts ↗
- Control the concurrency of workflows and jobs ↗
- Reusing workflows ↗
- GitHub Actions secure-use reference ↗
- GitHub Actions event reference ↗
Adapted from technical notes. Examples use generic names.