OFFSET偏移
← All articlesOFFSET / FIELD NOTESNO. 106

The GitHub Actions execution model

Understand events, workflows, jobs, runners, and steps, along with the distinct roles of matrices, caches, and artifacts.

English translation · Code and commands follow the Chinese original.

IN THESE READING PATHSGitHub Actions and CI ↗
About 15 min readStart reading

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. Without branches, branches-ignore, tags, or tags-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, branches matches 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 set types if only particular changes should trigger a run.
  • workflow_dispatch: runs a workflow manually. You can declare inputs so 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 replace push/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 --check

Here, 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

  • run passes single-line or multiline commands to the runner's shell. It suits a project's own checks, such as nix flake check, git diff --check, or npm test.
  • uses calls an action. An action can be a JavaScript action, Docker action, or composite action; it accepts inputs through with and exposes results through outputs.
  • Step-level uses differs from job-level uses: the former calls an action, while the latter calls a reusable workflow_call workflow.

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.
  • $MODE is an environment variable expanded by the runner's shell when it executes run.

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: read

This 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.sh

needs 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: true

This 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
          fi

Read 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.branches matches the branch being pushed; pull_request.branches matches the PR's target branch.
  • The same event cannot use both branches and branches-ignore. To include and exclude branches together, specify branches with ordered ! patterns.
  • The same event cannot use both paths and paths-ignore. If branch and path filters are both configured, both conditions must match for a run to start.
  • Positive and negative paths patterns 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 tags or tags-ignore is 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#

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

  1. Workflow syntax for GitHub Actions ↗
  2. Events that trigger workflows ↗
  3. Running variations of jobs with a matrix ↗
  4. Dependency caching reference ↗
  5. Store and share data with workflow artifacts ↗
  6. Control the concurrency of workflows and jobs ↗
  7. Reusing workflows ↗
  8. GitHub Actions secure-use reference ↗
  9. GitHub Actions event reference ↗

Adapted from technical notes. Examples use generic names.

Continue along a reading path

GitHub Actions and CI

  1. 01The GitHub Actions execution model
  2. 02Configuring CI with GitHub Actions
  3. 03GitHub Actions security and troubleshooting
  4. 04Nix CI: evaluation, builds, and output contracts
CONTINUE READING / NEXT ARTICLEConfiguring CI with GitHub Actions

Design CI from local quality checks and connect stable check statuses to repository merge rules.